diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index cfc59d7..f348991 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -1,7 +1,7 @@ # Copyright (c) Tailscale Inc & AUTHORS # SPDX-License-Identifier: BSD-3-Clause -cmake_minimum_required(VERSION 3.4...3.18) +cmake_minimum_required(VERSION 3.5...3.18) project(tailscale) add_subdirectory(pybind11) diff --git a/python/Makefile b/python/Makefile index 52c2c6e..906c2d4 100644 --- a/python/Makefile +++ b/python/Makefile @@ -3,7 +3,6 @@ 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 . diff --git a/python/examples/echo.py b/python/examples/echo.py index e6615f3..73b7079 100644 --- a/python/examples/echo.py +++ b/python/examples/echo.py @@ -1,41 +1,46 @@ # 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 +import signal +import sys 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="") + """Handle a single connection - echo all received data.""" + try: + while True: + data = conn.read(2048) + if not data: # Connection closed + break + try: + print(data.decode('utf-8'), end="") + except UnicodeDecodeError: + print(data.decode('utf-8', errors='replace'), end="") + finally: + conn.close() def main(): - procs = [] + def shutdown(signum, frame): + print("\nShutting down...") + sys.exit(0) - ts = TSNet(ephemeral=True) - ts.up() + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) - ln = ts.listen("tcp", ":1999") - while True: - while procs: - pid, exit_code = os.waitpid(-1, os.WNOHANG) - if pid == 0: - break - procs.remove(pid) + # Get auth key from environment + # If not provided, library outputs an auth URL + authkey = os.environ.get('TS_AUTHKEY') - conn = ln.accept() - pid = os.fork() - if pid == 0: - return handler(conn) - procs.append(pid) + with TSNet(ephemeral=True, authkey=authkey) as ts: + ts.up() - ln.close() - ts.close() + with ts.listen("tcp", ":1999") as ln: + print("Listening on :1999") + while True: + conn = ln.accept() + handler(conn) if __name__ == "__main__": diff --git a/python/src/main.cpp b/python/src/main.cpp index 7c8b55b..e0d05ab 100644 --- a/python/src/main.cpp +++ b/python/src/main.cpp @@ -3,12 +3,39 @@ #include #include "libtailscale.h" +#include +#include #define STRINGIFY(x) #x #define MACRO_STRINGIFY(x) STRINGIFY(x) namespace py = pybind11; +// tailscale_accept +static int accept_connection(int ld, int* conn_out) { + struct msghdr msg = {0}; + + char mbuf[256]; + struct iovec io = { .iov_base = mbuf, .iov_len = sizeof(mbuf) }; + msg.msg_iov = &io; + msg.msg_iovlen = 1; + + char cbuf[256]; + msg.msg_control = cbuf; + msg.msg_controllen = sizeof(cbuf); + + if (recvmsg(ld, &msg, 0) == -1) { + return -1; + } + + struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); + unsigned char* data = CMSG_DATA(cmsg); + + int fd = *(int*)data; + *conn_out = fd; + return 0; +} + PYBIND11_MODULE(_tailscale, m) { m.doc() = R"pbdoc( Embedded Tailscale @@ -37,35 +64,35 @@ PYBIND11_MODULE(_tailscale, m) { )pbdoc"); m.def("err_msg", &TsnetErrmsg, R"pbdoc( - + Get error message for a server )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("accept", [](int ld) { int connOut; int rv = TsnetAccept(ld, &connOut); return std::make_tuple(connOut, rv);}, R"pbdoc( + m.def("accept", [](int ld) { int connOut; int rv = accept_connection(ld, &connOut); return std::make_tuple(connOut, rv);}, R"pbdoc( Accept a given listener and connection )pbdoc"); - m.def("dial", &TsnetDial, R"pbdoc( - + m.def("dial", [](int sd, char* network, char* addr) { int connOut; int rv = TsnetDial(sd, network, addr, &connOut); return std::make_tuple(connOut, rv); }, R"pbdoc( + Dial a connection on the tailnet )pbdoc"); m.def("set_dir", &TsnetSetDir, R"pbdoc( - + Set the state directory )pbdoc"); m.def("set_hostname", &TsnetSetHostname, R"pbdoc( - + Set the hostname )pbdoc"); m.def("set_authkey", &TsnetSetAuthKey, R"pbdoc( - + Set the auth key )pbdoc"); m.def("set_control_url", &TsnetSetControlURL, R"pbdoc( - + Set the control URL )pbdoc"); m.def("set_ephemeral", &TsnetSetEphemeral, R"pbdoc( @@ -73,11 +100,11 @@ PYBIND11_MODULE(_tailscale, m) { )pbdoc"); m.def("set_log_fd", &TsnetSetLogFD, R"pbdoc( - + Set the log file descriptor )pbdoc"); m.def("loopback", &TsnetLoopback, R"pbdoc( - + Start a loopback server )pbdoc"); #ifdef VERSION_INFO diff --git a/python/tailscale/tsnet.py b/python/tailscale/tsnet.py index a9bc60d..bd0d0ff 100644 --- a/python/tailscale/tsnet.py +++ b/python/tailscale/tsnet.py @@ -1,44 +1,206 @@ # Copyright (c) Tailscale Inc & AUTHORS # SPDX-License-Identifier: BSD-3-Clause +""" +Tailscale embedded network interface for Python. + +This module provides a Python interface to run an embedded Tailscale node +within your application, allowing you to listen for and dial connections +to other nodes on your tailnet. +""" + import io +import os from . import _tailscale -class TSNetException(Exception): pass + +class TSNetException(Exception): + """Exception raised for Tailscale errors.""" + pass class TSNet: - def __init__(self, ephemeral=False): + """ + Tailscale network server. + + This class represents an embedded Tailscale node. Use it to connect to + your tailnet and listen for or dial connections to other nodes. + + Example: + with TSNet(ephemeral=True) as ts: + ts.set_authkey(os.environ['TS_AUTHKEY']) + ts.up() + + with ts.listen("tcp", ":1999") as ln: + conn = ln.accept() + data = conn.read(1024) + """ + + def __init__(self, ephemeral=False, authkey=None, hostname=None, dir=None): + """ + Create a new Tailscale server. + + Args: + ephemeral: If True, this node will be removed when it disconnects + authkey: Optional auth key for automatic authentication + hostname: Optional hostname for this node + dir: Optional directory for storing Tailscale state + """ self.ts = _tailscale.new() + if self.ts < 0: + raise TSNetException("Failed to create Tailscale server") + if ephemeral and _tailscale.set_ephemeral(self.ts, 1): - raise TSNetException("Error setting ephemeral") + raise TSNetException("Error setting ephemeral mode") + + if authkey: + self.set_authkey(authkey) + + if hostname: + self.set_hostname(hostname) + + if dir: + self.set_dir(dir) + + def set_authkey(self, authkey): + """ + Set the auth key for automatic node authentication. + + Args: + authkey: Tailscale auth key (e.g., from admin console) + """ + if _tailscale.set_authkey(self.ts, authkey): + raise TSNetException("Failed to set auth key") + + def set_hostname(self, hostname): + """ + Set the hostname for this Tailscale node. + + Args: + hostname: Desired hostname for the node + """ + if _tailscale.set_hostname(self.ts, hostname): + raise TSNetException("Failed to set hostname") + + def set_dir(self, dir): + """ + Set the directory for storing Tailscale state. + + Args: + dir: Path to state directory + """ + if _tailscale.set_dir(self.ts, dir): + raise TSNetException("Failed to set state directory") + + def set_control_url(self, url): + """ + Set the control server URL. + + Args: + url: Control server URL + """ + if _tailscale.set_control_url(self.ts, url): + raise TSNetException("Failed to set control URL") + + def set_log_fd(self, fd): + """ + Set the file descriptor for Tailscale logs. + + Args: + fd: File descriptor for logging (use -1 to disable) + """ + if _tailscale.set_log_fd(self.ts, fd): + raise TSNetException("Failed to set log file descriptor") def up(self): + """ + Bring up the Tailscale connection. + + This will block until the node is connected and ready to use. + If an auth key was not provided, you may need to authenticate + via the URL printed to the logs. + """ if _tailscale.up(self.ts): - raise TSNetException("Error coming up") + raise TSNetException("Failed to bring up Tailscale connection") def listen(self, proto, addr): + """ + Listen for connections on the tailnet. + + Args: + proto: Protocol ("tcp" or "udp") + addr: Address to listen on (e.g., ":8080") + + Returns: + TSNetListener object + """ ln, err = _tailscale.listen(self.ts, proto, addr) if err: - raise TSNetException("Error listening: %s on %s" % (proto, addr)) + raise TSNetException(f"Failed to listen on {proto} {addr}") return TSNetListener(ln) def close(self): + """Close the Tailscale server and release resources.""" if _tailscale.close(self.ts): - raise TSNetException("Failed to close") + raise TSNetException("Failed to close Tailscale server") + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager cleanup.""" + self.close() + return False class TSNetListener: + """ + A listener for incoming connections on the tailnet. + + Use accept() to wait for and accept incoming connections. + """ + def __init__(self, ln): + """ + Create a listener (internal use only). + + Args: + ln: Listener file descriptor from C library + """ self.ln = ln + self._closed = False def accept(self): + """ + Accept an incoming connection. + + This blocks until a connection is received. + + Returns: + File object for reading/writing to the connection + """ + if self._closed: + raise TSNetException("Listener is closed") + fd, err = _tailscale.accept(self.ln) if err: - raise TSNetException("Failed to accept conn") - return fd + raise TSNetException("Failed to accept connection") + + return os.fdopen(fd, 'rb+', buffering=0) def close(self): - if _tailscale.close_listener(self.ln): - raise TSNetException("Failed to close") + """Close the listener.""" + if not self._closed: + os.close(self.ln) + self._closed = True + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - ensures cleanup.""" + self.close() + return False