python: add initial support for Python

Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
This commit is contained in:
Shayne Sweeney
2023-03-04 22:26:14 -05:00
committed by shayne
parent 7bb5e96b97
commit 2df6a30f8a
12 changed files with 529 additions and 0 deletions

12
python/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
.venv/
__pycache__/
tailscale.egg-info/
dist/
build/
*.whl
pybind11/
libtailscale.a
libtailscale.h

17
python/CMakeLists.txt Normal file
View File

@@ -0,0 +1,17 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
cmake_minimum_required(VERSION 3.4...3.18)
project(tailscale)
add_subdirectory(pybind11)
link_directories(.)
pybind11_add_module(_tailscale src/main.cpp)
target_link_libraries(_tailscale PRIVATE tailscale)
target_compile_definitions(_tailscale PRIVATE VERSION_INFO=${TAILSCALE_VERSION_INFO})
target_include_directories(_tailscale PRIVATE .)

16
python/Makefile Normal file
View File

@@ -0,0 +1,16 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
build:
@git clone https://github.com/pybind/pybind11 || true
cd pybind11 && git checkout 3cc7e4258c15a6a19ba5e0b62a220b1a6196d4eb
cd .. && go build -buildmode=c-archive -o python/libtailscale.a github.com/tailscale/libtailscale
pip install .
wheel:
pip wheel .
clean:
rm -rf pybind11/ libtailscale.a libtailscale.h dist/ build/ tailscale.egg-info/
.PHONY: build wheel clean

43
python/README.md Normal file
View File

@@ -0,0 +1,43 @@
# tailscale
The tailscale Python package provides an embedded network interface that can be
used to listen for and dial connections to other [Tailscale](https://tailscale.com) nodes.
## Build and Install
Build Requirements:
- Python 3.9 or greater
- A recent Go compiler in $PATH
- CMake (and a C compiler)
- Git
Start by creating a virtualenv:
$ python3 -m venv venv
$ source venv/bin/activate
Install build dependencies, build the c-archive, and install the Python package in your virtualenv:
$ make build
Run example echo server:
$ python3 examples/echo.py
Build a distributable wheel:
$ make wheel
=> tailscale-0.0.1-cp310-cp310-linux_x86_64.whl
## Usage
The node will need to be authorized in order to function. Set an auth key via
the `$TS_AUTHKEY` environment variable, with `TSNet.set_authkey`, or watch the log
stream and respond to the printed authorization URL.
## Contributing
Pull requests are welcome on GitHub at https://github.com/tailscale/libtailscale
Please file any issues about this code or the hosted service on
[the issue tracker](https://github.com/tailscale/tailscale/issues).

42
python/examples/echo.py Normal file
View File

@@ -0,0 +1,42 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# TODO(shayne): proper select/poll/epoll + os.set_blocking(conn, False)
import os
import select
from tailscale import TSNet
def handler(conn):
while True:
r, _, _ = select.select([conn], [], [], 10)
if not conn in r:
os._exit(0)
data = os.read(conn, 2048)
print(data.decode(), end="")
def main():
procs = []
ts = TSNet(ephemeral=True)
ts.up()
ln = ts.listen("tcp", ":1999")
while True:
while procs:
pid, exit_code = os.waitpid(-1, os.WNOHANG)
if pid == 0:
break
procs.remove(pid)
conn = ln.accept()
pid = os.fork()
if pid == 0:
return handler(conn)
procs.append(pid)
ln.close()
ts.close()
if __name__ == "__main__":
main()

42
python/flake.lock generated Normal file
View File

@@ -0,0 +1,42 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1676283394,
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1677932085,
"narHash": "sha256-+AB4dYllWig8iO6vAiGGYl0NEgmMgGHpy9gzWJ3322g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3c5319ad3aa51551182ac82ea17ab1c6b0f0df89",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-unstable",
"type": "indirect"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

27
python/flake.nix Normal file
View File

@@ -0,0 +1,27 @@
{
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
devEnv = (pkgs.buildFHSUserEnv {
name = "libtailscale-python";
targetPkgs = pkgs: (with pkgs; [
cmake
python39
]);
runScript = "${pkgs.writeShellScriptBin "runScript" (''
set -e
python3 -m venv .venv
source .venv/bin/activate
exec bash
'')}/bin/runScript";
}).env;
in {
devShell = devEnv;
}
);
}

49
python/pyproject.toml Normal file
View File

@@ -0,0 +1,49 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
[build-system]
requires = [
"setuptools>=42",
"wheel",
"cmake>=3.12",
]
build-backend = "setuptools.build_meta"
[tool.mypy]
files = "setup.py"
python_version = "3.7"
strict = true
show_error_codes = true
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
warn_unreachable = true
[[tool.mypy.overrides]]
ignore_missing_imports = true
[tool.pytest.ini_options]
minversion = "6.0"
addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
xfail_strict = true
filterwarnings = ["error"]
testpaths = ["tests"]
[tool.cibuildwheel]
test-command = "pytest {project}/tests"
test-extras = ["test"]
test-skip = ["*universal2:arm64"]
# Setuptools bug causes collision between pypy and cpython artifacts
before-build = "rm -rf {project}/build"
[tool.ruff]
extend-select = [
"B", # flake8-bugbear
"B904",
"I", # isort
"PGH", # pygrep-hooks
"RUF", # Ruff-specific
"UP", # pyupgrade
]
extend-ignore = [
"E501", # Line too long
]
target-version = "py37"

141
python/setup.py Normal file
View File

@@ -0,0 +1,141 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
import os
import re
import subprocess
import sys
from pathlib import Path
from setuptools import Extension, setup
from setuptools.command.build_ext import build_ext
# Convert distutils Windows platform specifiers to CMake -A arguments
PLAT_TO_CMAKE = {
"win32": "Win32",
"win-amd64": "x64",
"win-arm32": "ARM",
"win-arm64": "ARM64",
}
# A CMakeExtension needs a sourcedir instead of a file list.
# The name must be the _single_ output extension from the CMake build.
# If you need multiple extensions, see scikit-build.
class CMakeExtension(Extension):
def __init__(self, name: str, sourcedir: str = "") -> None:
super().__init__(name, sources=[])
self.sourcedir = os.fspath(Path(sourcedir).resolve())
class CMakeBuild(build_ext):
def build_extension(self, ext: CMakeExtension) -> None:
# Must be in this form due to bug in .resolve() only fixed in Python 3.10+
ext_fullpath = Path.cwd() / self.get_ext_fullpath(ext.name) # type: ignore[no-untyped-call]
extdir = ext_fullpath.parent.resolve()
# Using this requires trailing slash for auto-detection & inclusion of
# auxiliary "native" libs
debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug
cfg = "Debug" if debug else "Release"
# CMake lets you override the generator - we need to check this.
# Can be set with Conda-Build, for example.
cmake_generator = os.environ.get("CMAKE_GENERATOR", "")
# Set Python_EXECUTABLE instead if you use PYBIND11_FINDPYTHON
# TAILSCALE_VERSION_INFO shows you how to pass a value into the C++ code
# from Python.
cmake_args = [
f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={extdir}{os.sep}",
f"-DPYTHON_EXECUTABLE={sys.executable}",
f"-DCMAKE_BUILD_TYPE={cfg}", # not used on MSVC, but no harm
]
build_args = []
# Adding CMake arguments set as environment variable
# (needed e.g. to build for ARM OSx on conda-forge)
if "CMAKE_ARGS" in os.environ:
cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item]
# In this example, we pass in the version to C++. You might not need to.
cmake_args += [f"-DTAILSCALE_VERSION_INFO={self.distribution.get_version()}"] # type: ignore[attr-defined]
if self.compiler.compiler_type != "msvc":
# Using Ninja-build since it a) is available as a wheel and b)
# multithreads automatically. MSVC would require all variables be
# exported for Ninja to pick it up, which is a little tricky to do.
# Users can override the generator with CMAKE_GENERATOR in CMake
# 3.15+.
if not cmake_generator or cmake_generator == "Ninja":
try:
import ninja
ninja_executable_path = Path(ninja.BIN_DIR) / "ninja"
cmake_args += [
"-GNinja",
f"-DCMAKE_MAKE_PROGRAM:FILEPATH={ninja_executable_path}",
]
except ImportError:
pass
else:
# Single config generators are handled "normally"
single_config = any(x in cmake_generator for x in {"NMake", "Ninja"})
# CMake allows an arch-in-generator style for backward compatibility
contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"})
# Specify the arch if using MSVC generator, but only if it doesn't
# contain a backward-compatibility arch spec already in the
# generator name.
if not single_config and not contains_arch:
cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]]
# Multi-config generators have a different way to specify configs
if not single_config:
cmake_args += [
f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{cfg.upper()}={extdir}"
]
build_args += ["--config", cfg]
if sys.platform.startswith("darwin"):
# Cross-compile support for macOS - respect ARCHFLAGS if set
archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", ""))
if archs:
cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))]
# Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level
# across all generators.
if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ:
# self.parallel is a Python 3 only way to set parallel jobs by hand
# using -j in the build_ext call, not supported by pip or PyPA-build.
if hasattr(self, "parallel") and self.parallel:
# CMake 3.12+ only.
build_args += [f"-j{self.parallel}"]
build_temp = Path(self.build_temp) / ext.name
if not build_temp.exists():
build_temp.mkdir(parents=True)
subprocess.run(
["cmake", ext.sourcedir, *cmake_args], cwd=build_temp, check=True
)
subprocess.run(
["cmake", "--build", ".", *build_args], cwd=build_temp, check=True
)
setup(
name="tailscale",
version="0.0.1",
author="Tailscale Inc & AUTHORS",
author_email="support@tailscale.com",
description="Embedded Tailscale",
long_description="",
ext_modules=[CMakeExtension("tailscale._tailscale")],
packages=["tailscale"],
cmdclass={"build_ext": CMakeBuild},
zip_safe=False,
extras_require={"test": ["pytest>=6.0"]},
python_requires=">=3.7",
)

92
python/src/main.cpp Normal file
View File

@@ -0,0 +1,92 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
#include <pybind11/pybind11.h>
#include "libtailscale.h"
#define STRINGIFY(x) #x
#define MACRO_STRINGIFY(x) STRINGIFY(x)
namespace py = pybind11;
PYBIND11_MODULE(_tailscale, m) {
m.doc() = R"pbdoc(
Embedded Tailscale
-----------------------
.. currentmodule:: _tailscale
.. autosummary::
:toctree: _generate
)pbdoc";
m.def("new", &TsnetNewServer, R"pbdoc(
Create a new tsnet server
)pbdoc");
m.def("start", &TsnetStart, R"pbdoc(
Starts a tsnet server
)pbdoc");
m.def("up", &TsnetUp, R"pbdoc(
Brings the given tsnet server up
)pbdoc");
m.def("close", &TsnetClose, R"pbdoc(
Closes a given tsnet server
)pbdoc");
m.def("err_msg", &TsnetErrmsg, R"pbdoc(
)pbdoc");
m.def("listen", [](int sd, char* network, char* addr) { int listenerOut; int rv = TsnetListen(sd, network, addr, &listenerOut); return std::make_tuple(listenerOut, rv); }, R"pbdoc(
Listen on a given protocol and port
)pbdoc");
m.def("close_listener", &TsnetListenerClose, R"pbdoc(
Create a new tsnet server
)pbdoc");
m.def("accept", [](int ld) { int connOut; int rv = TsnetAccept(ld, &connOut); return std::make_tuple(connOut, rv);}, R"pbdoc(
Accept a given listener and connection
)pbdoc");
m.def("dial", &TsnetDial, R"pbdoc(
)pbdoc");
m.def("set_dir", &TsnetSetDir, R"pbdoc(
)pbdoc");
m.def("set_hostname", &TsnetSetHostname, R"pbdoc(
)pbdoc");
m.def("set_authkey", &TsnetSetAuthKey, R"pbdoc(
)pbdoc");
m.def("set_control_url", &TsnetSetControlURL, R"pbdoc(
)pbdoc");
m.def("set_ephemeral", &TsnetSetEphemeral, R"pbdoc(
Set the given tsnet server to be an ephemeral node.
)pbdoc");
m.def("set_log_fd", &TsnetSetLogFD, R"pbdoc(
)pbdoc");
m.def("loopback_api", &TsnetLoopbackAPI, R"pbdoc(
)pbdoc");
#ifdef VERSION_INFO
m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO);
#else
m.attr("__version__") = "dev";
#endif
}

View File

@@ -0,0 +1,4 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
from .tsnet import TSNet, TSNetException

44
python/tailscale/tsnet.py Normal file
View File

@@ -0,0 +1,44 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
import io
from . import _tailscale
class TSNetException(Exception): pass
class TSNet:
def __init__(self, ephemeral=False):
self.ts = _tailscale.new()
if ephemeral and _tailscale.set_ephemeral(self.ts, 1):
raise TSNetException("Error setting ephemeral")
def up(self):
if _tailscale.up(self.ts):
raise TSNetException("Error coming up")
def listen(self, proto, addr):
ln, err = _tailscale.listen(self.ts, proto, addr)
if err:
raise TSNetException("Error listening: %s on %s" % (proto, addr))
return TSNetListener(ln)
def close(self):
if _tailscale.close(self.ts):
raise TSNetException("Failed to close")
class TSNetListener:
def __init__(self, ln):
self.ln = ln
def accept(self):
fd, err = _tailscale.accept(self.ln)
if err:
raise TSNetException("Failed to accept conn")
return fd
def close(self):
if _tailscale.close_listener(self.ln):
raise TSNetException("Failed to close")