From 48ede613bb75d3f41aebc04b521966215a5d4321 Mon Sep 17 00:00:00 2001 From: Josue Diaz Flores Date: Tue, 1 Sep 2026 21:33:24 -0700 Subject: [PATCH 1/6] libtailcat: add C bindings for the tailcat library --- .gitignore | 5 + libtailcat/Makefile | 75 + libtailcat/README.md | 93 ++ libtailcat/cgo_types.go | 39 + libtailcat/include/module.modulemap | 4 + libtailcat/include/tailcat.h | 325 +++++ libtailcat/libtailcat.go | 1444 ++++++++++++++++++++ libtailcat/libtailcat_test.go | 603 ++++++++ libtailcat/script/clangwrap-ios-sim-arm.sh | 17 + libtailcat/script/clangwrap-ios-sim-x86.sh | 17 + libtailcat/script/clangwrap-ios.sh | 17 + libtailcat/script/clangwrap-macos-x86.sh | 15 + libtailcat/sigpipe_darwin.go | 18 + libtailcat/sigpipe_other.go | 11 + libtailcat/tailcat.c | 166 +++ 15 files changed, 2849 insertions(+) create mode 100644 libtailcat/Makefile create mode 100644 libtailcat/README.md create mode 100644 libtailcat/cgo_types.go create mode 100644 libtailcat/include/module.modulemap create mode 100644 libtailcat/include/tailcat.h create mode 100644 libtailcat/libtailcat.go create mode 100644 libtailcat/libtailcat_test.go create mode 100755 libtailcat/script/clangwrap-ios-sim-arm.sh create mode 100755 libtailcat/script/clangwrap-ios-sim-x86.sh create mode 100755 libtailcat/script/clangwrap-ios.sh create mode 100755 libtailcat/script/clangwrap-macos-x86.sh create mode 100644 libtailcat/sigpipe_darwin.go create mode 100644 libtailcat/sigpipe_other.go create mode 100644 libtailcat/tailcat.c diff --git a/.gitignore b/.gitignore index 5ab7b0806..837c86ad4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ # Go test artifacts. *.test *.out + +# libtailcat / TailcatKit build outputs. +/libtailcat/build/ +/swift/CTailcat.xcframework +/swift/.build diff --git a/libtailcat/Makefile b/libtailcat/Makefile new file mode 100644 index 000000000..0da7f10c4 --- /dev/null +++ b/libtailcat/Makefile @@ -0,0 +1,75 @@ +# Copyright (c) Tailscale Inc & contributors +# SPDX-License-Identifier: BSD-3-Clause + +# Builds libtailcat as Go c-archives for macOS (arm64 + x86_64), iOS +# devices (arm64) and the iOS simulator (arm64 + x86_64), and packages +# them with include/ as ../swift/CTailcat.xcframework. +# +# Every archive is built with the release build tags from +# ../build-tags.txt, which trim the tailscale.com dependency down to +# what tailcat needs. + +GO ?= go +TAGS := $(shell cat ../build-tags.txt) +BUILD := build +SCRIPT := $(abspath script) +XCFRAMEWORK := ../swift/CTailcat.xcframework + +# `?=` so the environment can override them. +MACOS_TARGET ?= 14.0 + +export CGO_ENABLED = 1 +export MACOS_TARGET + +.PHONY: all +all: xcframework ## Builds everything (the default) + +$(BUILD): + mkdir -p $(BUILD) + +# macOS: arm64 natively, x86_64 through the clang wrapper, then lipo. +.PHONY: macos +macos: $(BUILD) ## Builds the fat macOS archive build/libtailcat_macos.a + MACOSX_DEPLOYMENT_TARGET=$(MACOS_TARGET) GOOS=darwin GOARCH=arm64 \ + CGO_CFLAGS="-mmacos-version-min=$(MACOS_TARGET)" CGO_LDFLAGS="-mmacos-version-min=$(MACOS_TARGET)" \ + $(GO) build -buildmode=c-archive -tags "$(TAGS)" -ldflags "-s -w" -o $(BUILD)/libtailcat_macos_arm64.a . + MACOSX_DEPLOYMENT_TARGET=$(MACOS_TARGET) GOOS=darwin GOARCH=amd64 CC=$(SCRIPT)/clangwrap-macos-x86.sh \ + $(GO) build -buildmode=c-archive -tags "$(TAGS)" -ldflags "-s -w" -o $(BUILD)/libtailcat_macos_x86_64.a . + lipo -create -output $(BUILD)/libtailcat_macos.a $(BUILD)/libtailcat_macos_arm64.a $(BUILD)/libtailcat_macos_x86_64.a + +.PHONY: ios +ios: $(BUILD) ## Builds the iOS device archive build/libtailcat_ios.a (iOS SDK required) + GOOS=ios GOARCH=arm64 CC=$(SCRIPT)/clangwrap-ios.sh \ + $(GO) build -buildmode=c-archive -tags "ios,$(TAGS)" -ldflags -w -o $(BUILD)/libtailcat_ios.a . + +.PHONY: ios-sim +ios-sim: $(BUILD) ## Builds the fat iOS simulator archive build/libtailcat_iossim.a (iOS SDK required) + GOOS=ios GOARCH=arm64 CC=$(SCRIPT)/clangwrap-ios-sim-arm.sh \ + $(GO) build -buildmode=c-archive -tags "ios,$(TAGS)" -ldflags -w -o $(BUILD)/libtailcat_iossim_arm64.a . + GOOS=ios GOARCH=amd64 CC=$(SCRIPT)/clangwrap-ios-sim-x86.sh \ + $(GO) build -buildmode=c-archive -tags "ios,$(TAGS)" -ldflags -w -o $(BUILD)/libtailcat_iossim_x86_64.a . + lipo -create -output $(BUILD)/libtailcat_iossim.a $(BUILD)/libtailcat_iossim_arm64.a $(BUILD)/libtailcat_iossim_x86_64.a + +.PHONY: xcframework +xcframework: macos ios ios-sim ## Builds ../swift/CTailcat.xcframework from the three archives + rm -rf $(XCFRAMEWORK) + mkdir -p $(dir $(XCFRAMEWORK)) + xcodebuild -create-xcframework \ + -library $(BUILD)/libtailcat_macos.a -headers include \ + -library $(BUILD)/libtailcat_ios.a -headers include \ + -library $(BUILD)/libtailcat_iossim.a -headers include \ + -output $(XCFRAMEWORK) + +.PHONY: test +test: ## Runs the offline Go tests of the exported functions + cd .. && $(GO) test ./libtailcat/ -run . -count=1 + +.PHONY: clean +clean: ## Removes build/ and the xcframework + rm -rf $(BUILD) $(XCFRAMEWORK) + +.PHONY: help +help: ## Shows this help + @printf '\nSpecify a target. The choices are:\n\n' + @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}' + @printf '\n' diff --git a/libtailcat/README.md b/libtailcat/README.md new file mode 100644 index 000000000..fdc21ebb3 --- /dev/null +++ b/libtailcat/README.md @@ -0,0 +1,93 @@ +# libtailcat + +A C API over the [tailcat](../README.md) Go library, built with +`go build -buildmode=c-archive` for macOS, iOS and the iOS simulator and +packaged as `CTailcat.xcframework` for use from Swift (see `../swift/`, +the TailcatKit package) or plain C. + +The API is in [`include/tailcat.h`](include/tailcat.h). The Go side is +`libtailcat.go`; `tailcat.c` maps each `tailcat_*` function to the Go +export of the same name. + +## Building + +Requires Go (see `../go.mod` for the version) and Xcode with the iOS SDK. + +```sh +make xcframework # ../swift/CTailcat.xcframework (macOS, iOS, iOS simulator) +make macos # build/libtailcat_macos.a (arm64 + x86_64) only +make test # offline Go test of the exported functions +make clean +``` + +All archives are built with the release build tags from +`../build-tags.txt`. The minimum targets are macOS 14 and iOS 17 +(`MACOS_TARGET` overrides the former; the iOS minimum is in +`script/clangwrap-*.sh`). + +The cgo-generated `build/*.h` headers are build artifacts; only +`include/` ships in the xcframework. Linking the archive needs the +system frameworks the Go runtime uses on Darwin, typically +`CoreFoundation`, `Security` and `libresolv`. + +## Using it from C + +Every function is safe to call from any thread. Handle functions return +0 on success, `EBADF` for a bad handle, `ERANGE` for a too-small output +buffer and -1 for other errors, whose text `tailcat_errmsg` returns. +Blocking calls (server start, client ping, path, dial, token resolve) +do network work; keep them off UI threads. + +A server: + +```c +#include +#include +#include "tailcat.h" + +tailcat_handle sd = tailcat_server_new(); +tailcat_listener ln; +tailcat_server_listen(sd, 8080, &ln); // 0 = every port not otherwise listened on +if (tailcat_server_start(sd) != 0) { // blocks: DERP map, latency check, relay connect + char err[256]; + tailcat_errmsg(sd, err, sizeof err); + // ... +} +char token[512]; +tailcat_server_token(sd, token, sizeof token); // give this to clients + +for (;;) { + struct pollfd pfd = {.fd = ln, .events = POLLIN}; + poll(&pfd, 1, -1); // a connection is queued + tailcat_conn c; + if (tailcat_accept(ln, &c) != 0) break; + char remote[64]; + int port; + tailcat_conn_info(ln, c, remote, sizeof remote, &port); + // c is a socket: read(2), write(2), shutdown(2) for half-close, close(2) +} +close(ln); // stops listening on the port +tailcat_server_close(sd); +``` + +A client: + +```c +tailcat_handle cd = tailcat_client_new(token); // 0 if the token is malformed +double ms; +tailcat_client_ping(cd, 10000, &ms); // blocks; brings the tunnel up +tailcat_conn c; +tailcat_client_dial(cd, 8080, 15000, &c); // TCP to the server's port 8080 +write(c, "hello\n", 6); +shutdown(c, SHUT_WR); // half-close: the server sees EOF +// read(c, ...) until 0 +close(c); +tailcat_client_close(cd); +``` + +Connections are one end of a socketpair pumped by Go, so they behave like +sockets; on Apple platforms they have `SO_NOSIGPIPE` set. Keys and tokens +can be handled without a handle: `tailcat_key_generate`, +`tailcat_key_public`, `tailcat_key_token`, `tailcat_token_parse` and +`tailcat_token_resolve` return `NULL` or a malloc'd error string, and +their outputs are malloc'd too; `free()` all of them. diff --git a/libtailcat/cgo_types.go b/libtailcat/cgo_types.go new file mode 100644 index 000000000..227f1ecb9 --- /dev/null +++ b/libtailcat/cgo_types.go @@ -0,0 +1,39 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build unix + +package main + +// Go doesn't allow cgo in _test.go files, so the C types, constants and +// helpers that libtailcat_test.go needs to call the exported functions +// are given Go names here. Nothing else uses them. + +/* +#include +#include +*/ +import "C" + +import "unsafe" + +type ( + cChar = C.char + cInt = C.int + cSize = C.size_t + cDouble = C.double +) + +const ( + cEBADF = C.EBADF + cERANGE = C.ERANGE +) + +// cString returns s as a malloc'd C string; free it with cFree. +func cString(s string) *C.char { return C.CString(s) } + +// cFree releases a malloc'd C string. +func cFree(p *C.char) { C.free(unsafe.Pointer(p)) } + +// goString copies the C string p into a Go string. +func goString(p *C.char) string { return C.GoString(p) } diff --git a/libtailcat/include/module.modulemap b/libtailcat/include/module.modulemap new file mode 100644 index 000000000..c2a81e83d --- /dev/null +++ b/libtailcat/include/module.modulemap @@ -0,0 +1,4 @@ +module CTailcat { + header "tailcat.h" + export * +} diff --git a/libtailcat/include/tailcat.h b/libtailcat/include/tailcat.h new file mode 100644 index 000000000..19689d58e --- /dev/null +++ b/libtailcat/include/tailcat.h @@ -0,0 +1,325 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// libtailcat: a C API over the tailcat Go library. +// +// tailcat is a control-plane-free network pipe built on Tailscale's data +// plane (WireGuard encryption, NAT traversal, DERP relays as bootstrap and +// fallback). A server announces a connection token; clients holding the +// token can dial TCP ports on the server, which hands each accepted +// connection to the caller as a file descriptor. +// +// Every connection given to C is one end of a socketpair(2) pumped by the +// Go side: use read(2), write(2), shutdown(2) and close(2) on it like a +// socket. shutdown(fd, SHUT_WR) is a TCP half-close: the peer's reads see +// EOF while its writes still reach you. close(2) tears the connection down. +// On Apple platforms every descriptor handed out has SO_NOSIGPIPE set, so +// writing to a dead connection fails with EPIPE instead of raising SIGPIPE. +// +// Every function is safe to call from any thread. Functions documented as +// blocking (server start, client ping, path, dial, token resolve) do +// network work and must not be called on a UI thread. + +#include + +#ifndef TAILCAT_H +#define TAILCAT_H + +#ifdef __cplusplus +extern "C" { +#endif + +// tailcat_handle refers to a server or a client object. +typedef int tailcat_handle; + +// tailcat_conn is one end of a socketpair: read(2), write(2), shutdown(2), +// close(2). See the file comment above. +typedef int tailcat_conn; + +// tailcat_listener is one end of a socketpair over which accepted +// connections arrive. poll(2) it for POLLIN, then call tailcat_accept. +// close(2) it to stop listening, which also unregisters its port. +typedef int tailcat_listener; + +// Handle functions return 0 on success, EBADF for an invalid handle (or a +// handle of the wrong kind, such as a client passed to a server function), +// ERANGE when an output buffer is too small (the output is still +// NUL-terminated), or -1 for any other error, whose text tailcat_errmsg +// returns. Functions taking an output buffer panic on a NULL buffer or a +// zero length, and functions taking an output pointer (listener_out, +// conn_out, json_out, ...) panic when it is NULL; that is a programming +// error, not a runtime condition. + +// tailcat_errmsg writes the text of the last error recorded on h to buf. +// +// After returning, buf is always NUL-terminated. +// +// Returns: +// 0 - success +// EBADF - h is not a valid handle +// ERANGE - insufficient storage for buf +extern int tailcat_errmsg(tailcat_handle h, char* buf, size_t buflen); + +// tailcat_set_logfd sends the object's Go-side logs to fd, one line per +// message. An fd of -1 discards them. By default they go to Go's log +// package, that is, to stderr. +// +// The descriptor stays owned by the caller and must remain open for the +// life of the handle; libtailcat never closes it. Call this before +// tailcat_server_start, or before a client's first ping, path or dial; +// afterwards it fails with -1. +extern int tailcat_set_logfd(tailcat_handle h, int fd); + +// Server. + +// tailcat_server_new creates a server object. Nothing touches the network +// until tailcat_server_start. +extern tailcat_handle tailcat_server_new(void); + +// tailcat_server_set_key sets the server's identity from key_json, the JSON +// of a tailcat.PrivateKey as written by "tailcat genkey" (or returned by +// tailcat_key_generate). It adopts both the private key and the DERP +// region information recorded in the key's Public part: a fixed region +// ID, embedded relay hosts, or -1 for automatic selection. A key with no +// region information at all (a client key) means automatic selection. +// +// Without a key, the server generates an ephemeral one. Call before start. +extern int tailcat_server_set_key(tailcat_handle sd, const char* key_json); + +// tailcat_server_set_region_id selects the DERP map region the server +// listens on: a positive region ID from the DERP map, or 0 to pick the +// nearest region by latency at start. It overrides the region information +// from the key and any earlier tailcat_server_set_relay_hosts call: of the +// key, set_region_id and set_relay_hosts, the last one applied wins. +// +// Call before start. +extern int tailcat_server_set_region_id(tailcat_handle sd, int region_id); + +// tailcat_server_set_relay_hosts uses your own DERP relay instead of one +// from the DERP map: hosts is a comma-separated list of DERP server +// hostnames. Like the tailcat CLI, the server connects to the first host +// listed. Because such a relay has no DERP map region ID to reference, the +// token always embeds the relay details. It overrides the region from the +// key and any earlier tailcat_server_set_region_id call. +// +// Call before start. +extern int tailcat_server_set_relay_hosts(tailcat_handle sd, const char* hosts); + +// tailcat_server_set_derpmap_url sets the URL of the JSON DERP map used to +// resolve or auto-select the region. The default is +// tailcat.DefaultDERPMapURL (https://tailcat.dev/derpmap.json). +// +// Call before start. +extern int tailcat_server_set_derpmap_url(tailcat_handle sd, const char* url); + +// tailcat_server_set_embed_relay controls the token form. With embed set +// to 1, the token embeds the relay's details (hostname and addresses) so +// that clients need no DERP map fetch; it is longer, like the output of +// "tailcat serve --full-address". With 0 (the default) the token carries +// just the DERP map region ID. Relay hosts set with +// tailcat_server_set_relay_hosts are always embedded. +// +// Call before start. +extern int tailcat_server_set_embed_relay(tailcat_handle sd, int embed); + +// tailcat_server_allow_client restricts the server to known clients: +// nodekey is a client's public key in the "nodekey:" form that +// tailcat_client_public_key returns. Until any key is allowed, every +// client may connect; once one is, only allowed keys can. It may be called +// before or after start. +extern int tailcat_server_allow_client(tailcat_handle sd, const char* nodekey); + +// tailcat_server_listen registers port (1-65535) for incoming connections +// and writes the new listener to listener_out. Port 0 registers a +// catch-all listener that receives connections to every port that has no +// listener of its own. Connections to ports with no listener (and no +// catch-all) are refused with a TCP RST. +// +// A port may be registered once until its listener is closed. Listening +// works before and after start. +// +// Returns zero on success or -1 on error, call tailcat_errmsg for details. +extern int tailcat_server_listen(tailcat_handle sd, int port, tailcat_listener* listener_out); + +// tailcat_server_start resolves the DERP region (fetching the DERP map and +// running a latency check as needed) and starts the server. It blocks for +// the duration, typically a few seconds; never call it on a UI thread. A +// server starts once. +// +// Like the tailcat CLI, it returns once the server is configured; the +// connection to the relay itself completes in the background shortly +// after. A client pinging in that window gets no answer (see +// tailcat_client_ping) and should retry. +// +// Returns zero on success or -1 on error, call tailcat_errmsg for details. +extern int tailcat_server_start(tailcat_handle sd); + +// tailcat_server_token writes the server's connection token, the string +// clients pass to tailcat_client_new (or to the tailcat CLI), to buf. +// Valid after start. +// +// Returns 0, EBADF, ERANGE or -1 as described at the top of this file. +extern int tailcat_server_token(tailcat_handle sd, char* buf, size_t buflen); + +// tailcat_server_public_key writes the server's node public key +// ("nodekey:") to buf. Valid any time after tailcat_server_new. +extern int tailcat_server_public_key(tailcat_handle sd, char* buf, size_t buflen); + +// tailcat_server_status_json writes the server's WireGuard and DERP status +// (the JSON encoding of ipnstate.Status) to *json_out as a malloc'd, +// NUL-terminated string that the caller releases with free(). Valid after +// start. +// +// Returns: +// 0 - success, *json_out is set +// EBADF - sd is not a valid server handle +// -1 - call tailcat_errmsg for details (*json_out is NULL) +extern int tailcat_server_status_json(tailcat_handle sd, char** json_out); + +// tailcat_server_close shuts the server down: every listener and every +// accepted connection is closed on the Go side (reads on their +// descriptors return EOF; the caller still close(2)s the descriptors it +// holds), the relay connection is torn down and the handle is freed. It +// may be called before start. A second call returns EBADF. +// +// Returns: +// 0 - success +// EBADF - sd is not a valid server handle +// -1 - other error, details go to the logger +extern int tailcat_server_close(tailcat_handle sd); + +// tailcat_accept dequeues the next accepted connection from listener l +// into conn_out. It blocks until a connection is queued; poll(2) the +// listener for POLLIN first to avoid blocking. Once the listener is closed +// it fails with -1. +// +// Returns: +// 0 - success +// EBADF - l is not a valid listener +// -1 - call tailcat_errmsg (on the server handle) for details +extern int tailcat_accept(tailcat_listener l, tailcat_conn* conn_out); + +// tailcat_conn_info describes a connection c accepted from listener l: the +// peer's address as "ip:port" (an IPv6 address in brackets) is written to +// remote_buf and the server port the peer dialed to *local_port_out, which +// may be NULL. It only knows connections returned by tailcat_accept on l, +// not connections from tailcat_client_dial. +// +// Returns: +// 0 - success +// EBADF - l is not a valid listener, or c was not accepted from it +// ERANGE - insufficient storage for remote_buf +extern int tailcat_conn_info(tailcat_listener l, tailcat_conn c, char* remote_buf, size_t remote_buflen, int* local_port_out); + +// Client. + +// tailcat_client_new creates a client for the server named by token. +// It returns 0 if the token is malformed (there is no handle to record an +// error on), otherwise a handle. Nothing happens on the network until the +// first ping, path or dial, which brings the client up: it resolves the +// server's relay (fetching the DERP map if the token doesn't embed it), +// connects to it and registers with the server. +extern tailcat_handle tailcat_client_new(const char* token); + +// tailcat_client_set_key sets the client's identity from key_json, the JSON +// of a tailcat.PrivateKey (only its Private part is used), so a server can +// allow it by public key. Without one, an ephemeral key is generated. +// Call before the client's first use, and before +// tailcat_client_public_key; afterwards it fails with -1. +extern int tailcat_client_set_key(tailcat_handle cd, const char* key_json); + +// tailcat_client_set_derpmap_url sets the DERP map URL used when the token +// doesn't embed the relay details. Call before the client's first use. +extern int tailcat_client_set_derpmap_url(tailcat_handle cd, const char* url); + +// tailcat_client_public_key writes the client's node public key +// ("nodekey:") to buf, generating the ephemeral key first if none was +// set. Give this to the server's tailcat_server_allow_client. +extern int tailcat_client_public_key(tailcat_handle cd, char* buf, size_t buflen); + +// tailcat_client_ping checks that the server is reachable and accepts this +// client, bringing the client up on first use. It measures the relay round +// trip and writes it in milliseconds to *latency_ms_out (which may be +// NULL). It blocks for up to timeout_ms milliseconds; a timeout of 0 or +// less means no limit beyond tailcat's own internal one. +// +// Each call sends one probe. A server that doesn't allow this client never +// answers, so a rejected client shows up as a timeout; so does a server +// that is still connecting to its relay right after tailcat_server_start, +// which is worth a retry. +// +// Returns zero on success or -1 on error, call tailcat_errmsg for details. +extern int tailcat_client_ping(tailcat_handle cd, int timeout_ms, double* latency_ms_out); + +// tailcat_client_path_json reports how packets reach the server: it sends +// a path-discovery ping (Client.DiscoPing) and writes the result, the JSON +// encoding of ipnstate.PingResult, to *json_out as a malloc'd string that +// the caller releases with free(). Endpoint is set when the pong came back +// over a direct path; otherwise DERPRegionID and DERPRegionCode name the +// relay. LatencySeconds is the round trip. Calling it repeatedly nudges +// direct path discovery along. It blocks for up to timeout_ms +// milliseconds (0 or less: no limit) and brings the client up on first +// use. +// +// Returns zero on success or -1 on error (*json_out is NULL), call +// tailcat_errmsg for details. +extern int tailcat_client_path_json(tailcat_handle cd, int timeout_ms, char** json_out); + +// tailcat_client_dial opens a TCP connection to port (1-65535) on the +// server's own address and writes it to conn_out. It blocks for up to +// timeout_ms milliseconds (0 or less: no limit) and brings the client up +// on first use. +// +// Returns zero on success or -1 on error, call tailcat_errmsg for details. +extern int tailcat_client_dial(tailcat_handle cd, int port, int timeout_ms, tailcat_conn* conn_out); + +// tailcat_client_close closes every connection dialed through the client +// on the Go side (reads on their descriptors return EOF; the caller still +// close(2)s them), shuts the tunnel down and frees the handle. A second +// call returns EBADF. +// +// Returns: +// 0 - success +// EBADF - cd is not a valid client handle +// -1 - other error, details go to the logger +extern int tailcat_client_close(tailcat_handle cd); + +// Keys and tokens. These take no handle. They return NULL on success or a +// malloc'd error string. All outputs are malloc'd and NUL-terminated; the +// caller releases outputs and error strings with free(). + +// tailcat_key_generate creates a new identity and writes its JSON (the +// format of "tailcat genkey" key files, a tailcat.PrivateKey with +// Public.RegionID = -1 for automatic region selection) to *key_json_out. +// It is a private key: store it accordingly. +extern char* tailcat_key_generate(char** key_json_out); + +// tailcat_key_public writes the public key ("nodekey:") of the +// identity in key_json to *nodekey_out. +extern char* tailcat_key_public(const char* key_json, char** nodekey_out); + +// tailcat_key_token writes the token that a server using key_json will +// announce to *token_out. That is only known ahead of time when the key +// names a fixed DERP region or embeds relay hosts; with automatic region +// selection (RegionID -1) it fails, and the token must instead be read +// from the running server with tailcat_server_token. +extern char* tailcat_key_token(const char* key_json, char** token_out); + +// tailcat_token_parse decodes token and writes its fields as JSON to +// *json_out: ServerPublic, ServerDiscoPublic, and either RegionID or the +// embedded Region. It is the same output as "tailcat parse". +extern char* tailcat_token_parse(const char* token, char** json_out); + +// tailcat_token_resolve writes a self-contained form of token, with the +// relay's details embedded, to *token_out, the same as "tailcat resolve". +// A token that already embeds them is returned unchanged. The DERP map is +// fetched from derpmap_url, or the default map when derpmap_url is NULL +// or empty. It blocks for up to timeout_ms milliseconds (0 or less: no +// limit beyond the fetch's own). +extern char* tailcat_token_resolve(const char* token, const char* derpmap_url, int timeout_ms, char** token_out); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/libtailcat/libtailcat.go b/libtailcat/libtailcat.go new file mode 100644 index 000000000..60e223de3 --- /dev/null +++ b/libtailcat/libtailcat.go @@ -0,0 +1,1444 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build unix + +// Command libtailcat is the Go side of the libtailcat C library, built +// with -buildmode=c-archive. See include/tailcat.h for the C API and +// tailcat.c for the shim that maps each tailcat_* function to the +// TailcatXxx function exported here. +// +// The design follows libtailscale (github.com/tailscale/libtailscale): +// handles are small integers in a process-wide table; every connection +// handed to C is one end of a socketpair(2) that goroutines pump to and +// from the tunnel connection; and a listener is a socketpair over which +// accepted connections are passed to C as descriptors with SCM_RIGHTS. +package main + +/* +#cgo CFLAGS: -I${SRCDIR}/include +#include +#include +*/ +import "C" + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "slices" + "strings" + "sync" + "syscall" + "time" + "unsafe" + + "github.com/tailscale/tailcat" + "golang.org/x/sys/unix" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + "tailscale.com/types/logger" +) + +func main() {} + +var ( + errServerStarted = errors.New("server already started") + errServerClosed = errors.New("server closed") + errNotStarted = errors.New("server not started") + errClientStarted = errors.New("client already started; set options before its first use") + errClientClosed = errors.New("client closed") + errKeyFixed = errors.New("client key already generated; set the key before asking for the public key") +) + +// objects is the handle table: every tailcat_handle given to C maps to a +// server or a client. Handles start well above any plausible descriptor +// number so the two can't be confused. +var objects struct { + mu sync.Mutex + next C.int + m map[C.int]*object +} + +// object is what a tailcat_handle refers to. Exactly one of s and c is +// non-nil. +type object struct { + s *server + c *client + + mu sync.Mutex + lastErr string +} + +// recErr records err as the object's last error, for tailcat_errmsg, and +// returns the C result code for it. +func (o *object) recErr(err error) C.int { + o.mu.Lock() + defer o.mu.Unlock() + if err == nil { + o.lastErr = "" + return 0 + } + o.lastErr = err.Error() + return -1 +} + +func (o *object) errmsg() string { + o.mu.Lock() + defer o.mu.Unlock() + return o.lastErr +} + +func newHandle(o *object) C.int { + objects.mu.Lock() + defer objects.mu.Unlock() + if objects.m == nil { + objects.m = map[C.int]*object{} + } + if objects.next == 0 { + objects.next = 42<<16 + 1 + } + h := objects.next + objects.next++ + objects.m[h] = o + return h +} + +func getObject(h C.int) *object { + objects.mu.Lock() + defer objects.mu.Unlock() + return objects.m[h] +} + +// getServer returns the server behind h, or nil if h is not a live server +// handle (a client handle counts as invalid here). +func getServer(h C.int) (*object, *server) { + o := getObject(h) + if o == nil || o.s == nil { + return nil, nil + } + return o, o.s +} + +// getClient is getServer for clients. +func getClient(h C.int) (*object, *client) { + o := getObject(h) + if o == nil || o.c == nil { + return nil, nil + } + return o, o.c +} + +// takeObject removes h from the handle table for closing, returning nil +// if h isn't a live handle of the requested kind. +func takeObject(h C.int, wantServer bool) *object { + objects.mu.Lock() + defer objects.mu.Unlock() + o := objects.m[h] + if o == nil || (wantServer && o.s == nil) || (!wantServer && o.c == nil) { + return nil + } + delete(objects.m, h) + return o +} + +// checkBuf panics on a nil or empty output buffer, which the C API +// documents as a programming error, so that every other path can leave +// the buffer NUL-terminated. +func checkBuf(fn string, buf *C.char, buflen C.size_t) { + if buf == nil { + panic(fn + " passed nil buf") + } else if buflen == 0 { + panic(fn + " passed buflen of 0") + } +} + +// cstrOut copies s into the C buffer buf of buflen bytes, always +// NUL-terminating it, and reports ERANGE if s didn't fit whole. +func cstrOut(buf *C.char, buflen C.size_t, s string) C.int { + out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) + n := copy(out, s) + if n >= len(out) { + out[len(out)-1] = '\x00' // always NUL-terminate + return C.ERANGE + } + out[n] = '\x00' + return 0 +} + +// cerr returns err as a malloc'd C string, or NULL for nil, the +// convention of the handle-free key and token functions. +func cerr(err error) *C.char { + if err == nil { + return nil + } + return C.CString(err.Error()) +} + +// timeoutContext returns a context that expires after timeoutMs +// milliseconds, or one with no deadline when timeoutMs is zero or less. +func timeoutContext(timeoutMs C.int) (context.Context, context.CancelFunc) { + if timeoutMs <= 0 { + return context.WithCancel(context.Background()) + } + return context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) +} + +//export TailcatErrmsg +func TailcatErrmsg(h C.int, buf *C.char, buflen C.size_t) C.int { + checkBuf("errmsg", buf, buflen) + o := getObject(h) + if o == nil { + *buf = '\x00' + return C.EBADF + } + return cstrOut(buf, buflen, o.errmsg()) +} + +//export TailcatSetLogFD +func TailcatSetLogFD(h, fd C.int) C.int { + o := getObject(h) + if o == nil { + return C.EBADF + } + logf := logfForFD(int(fd)) + if o.s != nil { + return o.s.configure(func() error { + o.s.logf = logf + return nil + }) + } + return o.c.configure(func() error { + o.c.cl.Logf = logf + return nil + }) +} + +// logfForFD returns a logger writing one line per message to the caller's +// descriptor fd, or one discarding everything for -1. The descriptor stays +// the caller's: it is written with plain write(2) calls and never wrapped +// in an os.File, whose finalizer would close it behind the caller's back. +func logfForFD(fd int) logger.Logf { + if fd == -1 { + return logger.Discard + } + var mu sync.Mutex + return func(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + if !strings.HasSuffix(msg, "\n") { + msg += "\n" + } + mu.Lock() + defer mu.Unlock() + b := []byte(msg) + for len(b) > 0 { + n, err := syscall.Write(fd, b) + if err == syscall.EINTR { + continue + } + if err != nil || n <= 0 { + return + } + b = b[n:] + } + } +} + +// server is the state behind a server handle: configuration until start, +// then the running tailcat.Server and its listeners. +type server struct { + obj *object + + mu sync.Mutex + priv key.NodePrivate // zero until first needed + ci tailcat.ConnInfo // where to listen; RegionID -1 means auto + derpMapURL string // "" means tailcat.DefaultDERPMapURL + embed bool // token embeds the relay details + allowed []key.NodePublic // allowed clients, in order of addition + logf logger.Logf // nil means log.Printf + ports map[uint16]*listener // by port; 0 is the catch-all + testRegion *tailcfg.DERPRegion // set by setServerRegionForTest + starting bool // a start is in progress + srv *tailcat.Server // non-nil once started + token tailcat.ConnBlob // valid once started + closed bool +} + +// logfOr returns the server's logger, defaulting to log.Printf like +// tailcat.Server does. +func (s *server) logfOr() logger.Logf { + s.mu.Lock() + defer s.mu.Unlock() + if s.logf != nil { + return s.logf + } + return log.Printf +} + +// privLocked returns the server's private key, generating one on first +// use. s.mu must be held. +func (s *server) privLocked() key.NodePrivate { + if s.priv.IsZero() { + s.priv = key.NewNode() + } + return s.priv +} + +// configure runs f with s.mu held, refusing once the server has started +// or is starting, since the settings f changes are read at start. +func (s *server) configure(f func() error) C.int { + s.mu.Lock() + var err error + switch { + case s.closed: + err = errServerClosed + case s.starting || s.srv != nil: + err = errServerStarted + default: + err = f() + } + s.mu.Unlock() + return s.obj.recErr(err) +} + +// parsePrivateKey decodes the JSON of a tailcat.PrivateKey, the format of +// "tailcat genkey" key files. +func parsePrivateKey(js string) (*tailcat.PrivateKey, error) { + pk := new(tailcat.PrivateKey) + if err := json.Unmarshal([]byte(js), pk); err != nil { + return nil, fmt.Errorf("parsing key JSON: %w", err) + } + if pk.Private.IsZero() { + return nil, errors.New("key JSON has no Private key") + } + return pk, nil +} + +//export TailcatServerNew +func TailcatServerNew() C.int { + o := &object{} + o.s = &server{obj: o, ci: tailcat.ConnInfo{RegionID: -1}} + return newHandle(o) +} + +//export TailcatServerSetKey +func TailcatServerSetKey(sd C.int, keyJSON *C.char) C.int { + o, s := getServer(sd) + if s == nil { + return C.EBADF + } + pk, err := parsePrivateKey(C.GoString(keyJSON)) + if err != nil { + return o.recErr(err) + } + return s.configure(func() error { + s.priv = pk.Private + s.ci = tailcat.ConnInfo{RegionID: pk.Public.RegionID, Region: pk.Public.Region} + if s.ci.RegionID == 0 && len(s.ci.Region) == 0 { + // A client key (genkey --client) carries no region; pick + // one automatically rather than failing at start. + s.ci.RegionID = -1 + } + return nil + }) +} + +//export TailcatServerSetRegionID +func TailcatServerSetRegionID(sd C.int, regionID C.int) C.int { + o, s := getServer(sd) + if s == nil { + return C.EBADF + } + if regionID < 0 { + return o.recErr(fmt.Errorf("invalid DERP region ID %d", regionID)) + } + return s.configure(func() error { + s.ci.Region = nil + s.ci.RegionID = tailcfg.DERPRegionID(regionID) + if regionID == 0 { + s.ci.RegionID = -1 // auto + } + return nil + }) +} + +//export TailcatServerSetRelayHosts +func TailcatServerSetRelayHosts(sd C.int, hosts *C.char) C.int { + o, s := getServer(sd) + if s == nil { + return C.EBADF + } + // Like "tailcat genkey --region=": a region with no DERP + // map ID whose nodes are just hostnames. Expand fills in the rest. + reg := &tailcfg.DERPRegion{} + for _, h := range strings.Split(C.GoString(hosts), ",") { + if h = strings.TrimSpace(h); h != "" { + reg.Nodes = append(reg.Nodes, &tailcfg.DERPNode{HostName: h}) + } + } + if len(reg.Nodes) == 0 { + return o.recErr(errors.New("no relay hosts given")) + } + return s.configure(func() error { + s.ci.Region = []*tailcfg.DERPRegion{reg} + s.ci.RegionID = 0 + return nil + }) +} + +//export TailcatServerSetDERPMapURL +func TailcatServerSetDERPMapURL(sd C.int, url *C.char) C.int { + _, s := getServer(sd) + if s == nil { + return C.EBADF + } + u := C.GoString(url) + return s.configure(func() error { + s.derpMapURL = u + return nil + }) +} + +//export TailcatServerSetEmbedRelay +func TailcatServerSetEmbedRelay(sd C.int, embed C.int) C.int { + _, s := getServer(sd) + if s == nil { + return C.EBADF + } + return s.configure(func() error { + s.embed = embed != 0 + return nil + }) +} + +//export TailcatServerAllowClient +func TailcatServerAllowClient(sd C.int, nodekey *C.char) C.int { + o, s := getServer(sd) + if s == nil { + return C.EBADF + } + var k key.NodePublic + if err := k.UnmarshalText([]byte(C.GoString(nodekey))); err != nil { + return o.recErr(fmt.Errorf("invalid node key: %w", err)) + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return o.recErr(errServerClosed) + } + // Keys added before start are passed to the Server at start; keys + // added while a start is in progress are applied when it finishes + // (see start); keys added after go straight to the Server. + s.allowed = append(s.allowed, k) + srv := s.srv + s.mu.Unlock() + if srv != nil { + srv.AddAllowedClient(k) + } + return o.recErr(nil) +} + +//export TailcatServerListen +func TailcatServerListen(sd C.int, port C.int, listenerOut *C.int) C.int { + if listenerOut == nil { + panic("server_listen passed nil listener_out") + } + o, s := getServer(sd) + if s == nil { + return C.EBADF + } + if port < 0 || port > 65535 { + return o.recErr(fmt.Errorf("invalid port %d", port)) + } + ln, err := s.listen(uint16(port)) + if err != nil { + return o.recErr(err) + } + *listenerOut = ln.fdC + return o.recErr(nil) +} + +//export TailcatServerStart +func TailcatServerStart(sd C.int) C.int { + o, s := getServer(sd) + if s == nil { + return C.EBADF + } + return o.recErr(s.start()) +} + +// start resolves the DERP region and starts the tailcat.Server, holding +// s.mu only around the state transitions, never across the network work. +func (s *server) start() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return errServerClosed + } + if s.starting || s.srv != nil { + s.mu.Unlock() + return errServerStarted + } + s.starting = true + priv := s.privLocked() + ci := s.ci + // A pre-populated region (relay hosts, from set_relay_hosts or the + // key) has no DERP map ID to reference, so its token always embeds + // the relay. Decide before Expand, which zeroes RegionID when it + // populates Region. + embed := s.embed || len(ci.Region) > 0 + if s.testRegion != nil { + ci = tailcat.ConnInfo{Region: []*tailcfg.DERPRegion{s.testRegion.Clone()}} + embed = true + } + url := s.derpMapURL + logf := s.logf + allowed := slices.Clone(s.allowed) + nAllowed := len(s.allowed) + s.mu.Unlock() + + srv, token, err := startServer(priv, ci, embed, url, logf, allowed, s.dispatch) + + s.mu.Lock() + defer s.mu.Unlock() + s.starting = false + if err != nil { + return err + } + if s.closed { + srv.Close() + return errServerClosed + } + s.srv = srv + s.token = token + // Keys allowed while the start was in progress. + for _, k := range s.allowed[nAllowed:] { + srv.AddAllowedClient(k) + } + return nil +} + +// startServer mirrors the tailcat CLI's server start sequence +// (cmd/tailcat/tailcat.go, func server): expand ci into a DERP region, +// trim the region to what's needed, build the token, and start the +// server. The token is built here rather than with Server.ConnBlob, which +// always embeds the full region, so that the short form (a DERP map +// region ID) is available. +func startServer(priv key.NodePrivate, ci tailcat.ConnInfo, embed bool, derpMapURL string, logf logger.Logf, allowed []key.NodePublic, onTCP func(uint16) func(net.Conn)) (*tailcat.Server, tailcat.ConnBlob, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + opts := []any{tailcat.ExpandForServer} + if derpMapURL != "" { + opts = append(opts, tailcat.DERPMapURL(derpMapURL)) + } + if err := ci.Expand(ctx, opts...); err != nil { + return nil, "", fmt.Errorf("resolving DERP region: %w", err) + } + if len(ci.Region) == 0 { + return nil, "", errors.New("no DERP region resolved") + } + // Work on a copy: the region may belong to the caller (the test hook) + // or be shared with the ConnInfo kept for a retried start. + reg := ci.Region[0].Clone() + clearUnnecessaryRegionFields(reg) + + tok := tailcat.ConnInfo{ + ServerPublic: tailcat.NodePublic{NodePublic: priv.Public()}, + ServerDiscoPublic: tailcat.DiscoPublicForNode(priv), + } + if embed { + tok.Region = []*tailcfg.DERPRegion{reg} + } else { + tok.RegionID = reg.RegionID + } + token := tok.ConnBlob() + + srv := &tailcat.Server{ + Key: priv, + Logf: logf, + Region: reg, + AllowedClients: allowed, + OnTCP: onTCP, + } + if err := srv.Start(); err != nil { + return nil, "", err + } + return srv, token, nil +} + +// clearUnnecessaryRegionFields is copied from the tailcat CLI: it drops +// the parts of a DERP map region that neither the server nor the token +// needs, keeping a single relay node so both sides use the same one. +func clearUnnecessaryRegionFields(r *tailcfg.DERPRegion) { + r.Latitude = 0 + r.Longitude = 0 + r.RegionCode = "" + if len(r.Nodes) > 1 { + r.Nodes = r.Nodes[:1] + } + for _, n := range r.Nodes { + n.CanPort80 = false + n.RegionID = 0 + } +} + +// setServerRegionForTest makes the server at sd listen on reg without +// fetching a DERP map, embedding reg in its token, so tests can run +// against an in-process DERP server. Call it before start. +func setServerRegionForTest(sd C.int, reg *tailcfg.DERPRegion) { + _, s := getServer(sd) + if s == nil { + panic("setServerRegionForTest: not a server handle") + } + s.mu.Lock() + defer s.mu.Unlock() + s.testRegion = reg +} + +// dispatch is the Server.OnTCP callback. It is installed once at start +// and consults the port table on every connection, so listeners +// registered after start are honored too. Ports with no listener and no +// catch-all get a nil handler, which the server answers with a RST. +func (s *server) dispatch(port uint16) func(net.Conn) { + s.mu.Lock() + ln := s.ports[port] + if ln == nil { + ln = s.ports[0] + } + s.mu.Unlock() + if ln == nil { + return nil + } + return func(nc net.Conn) { ln.handoff(nc, port) } +} + +//export TailcatServerToken +func TailcatServerToken(sd C.int, buf *C.char, buflen C.size_t) C.int { + checkBuf("server_token", buf, buflen) + o, s := getServer(sd) + if s == nil { + *buf = '\x00' + return C.EBADF + } + s.mu.Lock() + token := s.token + started := s.srv != nil + s.mu.Unlock() + if !started { + *buf = '\x00' + return o.recErr(errNotStarted) + } + return cstrOut(buf, buflen, string(token)) +} + +//export TailcatServerPublicKey +func TailcatServerPublicKey(sd C.int, buf *C.char, buflen C.size_t) C.int { + checkBuf("server_public_key", buf, buflen) + _, s := getServer(sd) + if s == nil { + *buf = '\x00' + return C.EBADF + } + s.mu.Lock() + pub := s.privLocked().Public() + s.mu.Unlock() + return cstrOut(buf, buflen, pub.String()) +} + +//export TailcatServerStatusJSON +func TailcatServerStatusJSON(sd C.int, jsonOut **C.char) C.int { + if jsonOut == nil { + panic("server_status_json passed nil json_out") + } + *jsonOut = nil + o, s := getServer(sd) + if s == nil { + return C.EBADF + } + s.mu.Lock() + srv := s.srv + s.mu.Unlock() + if srv == nil { + return o.recErr(errNotStarted) + } + b, err := json.Marshal(srv.Status()) + if err != nil { + return o.recErr(err) + } + *jsonOut = C.CString(string(b)) + return o.recErr(nil) +} + +//export TailcatServerClose +func TailcatServerClose(sd C.int) C.int { + o := takeObject(sd, true) + if o == nil { + return C.EBADF + } + s := o.s + s.mu.Lock() + s.closed = true + srv := s.srv + s.srv = nil + var lns []*listener + for _, ln := range s.ports { + lns = append(lns, ln) + } + s.ports = nil + s.mu.Unlock() + + // Close the Go ends first so C-side reads see EOF right away, then + // the server itself. A start in progress finds s.closed set and + // closes the server it built. + for _, ln := range lns { + ln.cleanup() + } + closeConnsOf(o) + if srv != nil { + if err := srv.Close(); err != nil { + s.logfOr()("libtailcat: server close: %v", err) + return -1 + } + } + return 0 +} + +// listeners tracks every listener by the descriptor C holds, the +// tailcat_listener value. +var listeners struct { + mu sync.Mutex + m map[C.int]*listener +} + +// listener is a registered port. Accepted connections are passed to C over +// a socketpair: the Go end sends each connection's descriptor with +// SCM_RIGHTS, and C receives it with tailcat_accept. +type listener struct { + s *server + port uint16 + f *os.File // the Go end of the socketpair, pollable + fdC C.int // the C end, the tailcat_listener value + + sendMu sync.Mutex // serializes handoff messages so each is sent whole + + mu sync.Mutex + m map[C.int]accepted // by the descriptor tailcat_accept returned + + closeOnce sync.Once +} + +// accepted is what tailcat_conn_info reports for an accepted connection. +type accepted struct { + remote string + localPort uint16 +} + +// listen registers a listener for port, or fails if the port has one. +func (s *server) listen(port uint16) (*listener, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, errServerClosed + } + if old := s.ports[port]; old != nil { + // C may have just closed old's descriptor, in which case old's + // watch goroutine is about to unregister the port. Look at the + // socketpair directly so an immediate re-listen doesn't race it. + if !peerClosed(old.f) { + return nil, fmt.Errorf("port %d already has a listener", port) + } + delete(s.ports, port) + } + + // The tailcat_listener we return to C is one side of a socketpair(2). + // Connections are pushed through it as they arrive, so C can poll(2) + // the listener to learn when tailcat_accept won't block. + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + return nil, err + } + setNoSigPipe(fds[0]) + setNoSigPipe(fds[1]) + if err := syscall.SetNonblock(fds[1], true); err != nil { + syscall.Close(fds[0]) + syscall.Close(fds[1]) + return nil, err + } + ln := &listener{ + s: s, + port: port, + f: os.NewFile(uintptr(fds[1]), "tailcat-listener"), + fdC: C.int(fds[0]), + } + if s.ports == nil { + s.ports = map[uint16]*listener{} + } + s.ports[port] = ln + + listeners.mu.Lock() + if listeners.m == nil { + listeners.m = map[C.int]*listener{} + } + listeners.m[ln.fdC] = ln + listeners.mu.Unlock() + + go ln.watch() + return ln, nil +} + +// watch blocks until C closes its end of the socketpair. C never writes +// to its end, so a read only returns at EOF, or when the Go end is closed +// by tailcat_server_close. Either way the listener is then torn down. +func (ln *listener) watch() { + var buf [256]byte + for { + if _, err := ln.f.Read(buf[:]); err != nil { + break + } + } + ln.cleanup() +} + +// cleanup unregisters the listener and closes its Go end. It runs once, +// whether triggered by C closing the listener or by the server closing. +func (ln *listener) cleanup() { + ln.closeOnce.Do(func() { + // The descriptor number may already have been reused by a newer + // listener, so only remove entries that are still ours. + listeners.mu.Lock() + if listeners.m[ln.fdC] == ln { + delete(listeners.m, ln.fdC) + } + listeners.mu.Unlock() + + s := ln.s + s.mu.Lock() + if s.ports[ln.port] == ln { + delete(s.ports, ln.port) + } + s.mu.Unlock() + + ln.f.Close() + }) +} + +// A handoff message announces one accepted connection to tailcat_accept. +// It is fixed-size so that a recvmsg of exactly handoffLen bytes reads one +// message, and the connection's descriptor rides on its first byte as +// SCM_RIGHTS. Layout: local port (2 bytes, big endian), remote address +// length (1 byte), remote address, zero padding. +const handoffLen = 64 + +// handoff wraps an accepted tunnel connection to port in a socketpair and +// sends C's end through the listener. +func (ln *listener) handoff(nc net.Conn, port uint16) { + remote := nc.RemoteAddr().String() + c, cfd, err := newConn(ln.s.obj, nc) + if err != nil { + ln.s.logfOr()("libtailcat: accepting a connection on port %d: %v", port, err) + nc.Close() + return + } + msg := make([]byte, handoffLen) + binary.BigEndian.PutUint16(msg[0:2], port) + if max := handoffLen - 3; len(remote) > max { + remote = remote[:max] + } + msg[2] = byte(len(remote)) + copy(msg[3:], remote) + + ln.sendMu.Lock() + err = sendHandoff(ln.f, msg, syscall.UnixRights(cfd)) + ln.sendMu.Unlock() + syscall.Close(cfd) // C gets its own descriptor from recvmsg + if err != nil { + // The listener is closed (C closed it, or the server did). + ln.s.logfOr()("libtailcat: handing off a connection on port %d: %v", port, err) + c.cleanup() + } +} + +// sendHandoff sends msg over the pollable socket f with rights attached to +// its first byte, waiting for buffer space as needed. Only trailing bytes +// can be left by a short send, and they carry no rights. +func sendHandoff(f *os.File, msg, rights []byte) error { + rc, err := f.SyscallConn() + if err != nil { + return err + } + for sent := 0; sent < len(msg); { + oob := rights + if sent > 0 { + oob = nil + } + var n int + var serr error + if err := rc.Write(func(fd uintptr) bool { + n, serr = syscall.SendmsgN(int(fd), msg[sent:], oob, nil, 0) + return serr != syscall.EAGAIN && serr != syscall.EWOULDBLOCK && serr != syscall.EINTR + }); err != nil { + return err + } + if serr != nil { + return serr + } + sent += n + } + return nil +} + +// recvHandoff reads one handoff message from C's listener descriptor lfd, +// returning the connection descriptor it carried and the message. +func recvHandoff(lfd int) (connFd int, msg []byte, err error) { + msg = make([]byte, handoffLen) + oob := make([]byte, unix.CmsgSpace(4)) + connFd = -1 + for got := 0; got < handoffLen; { + var o []byte + if got == 0 { + o = oob // the rights ride on the first byte + } + n, oobn, _, _, err := syscall.Recvmsg(lfd, msg[got:], o, 0) + if err == syscall.EINTR { + continue + } + if err != nil { + return -1, nil, err + } + if n == 0 { + return -1, nil, errors.New("listener closed") + } + if oobn > 0 { + scms, err := syscall.ParseSocketControlMessage(o[:oobn]) + if err != nil { + return -1, nil, err + } + if len(scms) != 1 { + return -1, nil, fmt.Errorf("libtailcat: got %d control messages, want 1", len(scms)) + } + fds, err := syscall.ParseUnixRights(&scms[0]) + if err != nil { + return -1, nil, err + } + if len(fds) != 1 { + for _, fd := range fds { + syscall.Close(fd) + } + return -1, nil, fmt.Errorf("libtailcat: got %d descriptors, want 1", len(fds)) + } + connFd = fds[0] + } + got += n + } + if connFd < 0 { + return -1, nil, errors.New("libtailcat: handoff message carried no descriptor") + } + return connFd, msg, nil +} + +//export TailcatAccept +func TailcatAccept(l C.int, connOut *C.int) C.int { + if connOut == nil { + panic("accept passed nil conn_out") + } + listeners.mu.Lock() + ln := listeners.m[l] + listeners.mu.Unlock() + if ln == nil { + return C.EBADF + } + o := ln.s.obj + + fd, msg, err := recvHandoff(int(l)) + if err != nil { + return o.recErr(err) + } + info := accepted{ + localPort: binary.BigEndian.Uint16(msg[0:2]), + remote: string(msg[3 : 3+int(msg[2])]), + } + ln.mu.Lock() + if ln.m == nil { + ln.m = map[C.int]accepted{} + } + ln.m[C.int(fd)] = info + ln.mu.Unlock() + + *connOut = C.int(fd) + return o.recErr(nil) +} + +//export TailcatConnInfo +func TailcatConnInfo(l, c C.int, remoteBuf *C.char, remoteBuflen C.size_t, localPortOut *C.int) C.int { + checkBuf("conn_info", remoteBuf, remoteBuflen) + listeners.mu.Lock() + ln := listeners.m[l] + listeners.mu.Unlock() + if ln == nil { + *remoteBuf = '\x00' + return C.EBADF + } + ln.mu.Lock() + info, ok := ln.m[c] + ln.mu.Unlock() + if !ok { + *remoteBuf = '\x00' + return C.EBADF + } + if localPortOut != nil { + *localPortOut = C.int(info.localPort) + } + return cstrOut(remoteBuf, remoteBuflen, info.remote) +} + +// conns tracks every connection handed to C, for closing them along with +// their server or client. +var conns struct { + mu sync.Mutex + m map[*conn]struct{} +} + +// conn is a connection handed to C: one end of a socketpair went to C, +// the other, r, is pumped to and from the tunnel connection by two +// goroutines, one per direction. Each direction propagates its EOF as a +// half-close, and the connection is torn down once both directions are +// done, as soon as C closes its descriptor entirely, or when the owning +// server or client closes. +type conn struct { + owner *object + netConn net.Conn + r *os.File // the Go end of the socketpair, pollable + once sync.Once +} + +// newConn wraps the tunnel connection nc in a socketpair, returning the +// conn and the descriptor for C. +func newConn(owner *object, nc net.Conn) (*conn, int, error) { + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + return nil, -1, err + } + setNoSigPipe(fds[0]) + setNoSigPipe(fds[1]) + // Non-blocking so that os.File uses the runtime poller: goroutines + // blocked on r are woken by r.Close, which a plain blocking read of a + // closed descriptor would not be. + if err := syscall.SetNonblock(fds[1], true); err != nil { + syscall.Close(fds[0]) + syscall.Close(fds[1]) + return nil, -1, err + } + c := &conn{owner: owner, netConn: nc, r: os.NewFile(uintptr(fds[1]), "tailcat-conn")} + + conns.mu.Lock() + if conns.m == nil { + conns.m = map[*conn]struct{}{} + } + conns.m[c] = struct{}{} + conns.mu.Unlock() + + var wg sync.WaitGroup + wg.Add(2) + go func() { + // Tunnel to C. The peer's EOF becomes a write shutdown of C's + // end: C's reads return 0 while its writes still flow. + defer wg.Done() + var b [1 << 16]byte + io.CopyBuffer(c.r, nc, b[:]) + shutdown(c.r, syscall.SHUT_WR) + if cr, ok := nc.(interface{ CloseRead() error }); ok { + cr.CloseRead() + } + }() + go func() { + // C to tunnel. EOF from C is either shutdown(SHUT_WR), which + // becomes a half-close of the tunnel connection, or close(2), + // after which nothing can reach C anymore, so the connection is + // torn down right away instead of waiting for the peer. + defer wg.Done() + var b [1 << 16]byte + io.CopyBuffer(nc, c.r, b[:]) + if peerClosed(c.r) { + c.cleanup() + return + } + shutdown(c.r, syscall.SHUT_RD) + if cw, ok := nc.(interface{ CloseWrite() error }); ok { + cw.CloseWrite() + } else { + nc.Close() + } + }() + go func() { + wg.Wait() + c.cleanup() + }() + return c, fds[0], nil +} + +// cleanup closes both sides of the connection. It runs once. +func (c *conn) cleanup() { + c.once.Do(func() { + conns.mu.Lock() + delete(conns.m, c) + conns.mu.Unlock() + c.r.Close() + c.netConn.Close() + }) +} + +// closeConnsOf tears down every connection owned by o. +func closeConnsOf(o *object) { + conns.mu.Lock() + var list []*conn + for c := range conns.m { + if c.owner == o { + list = append(list, c) + } + } + conns.mu.Unlock() + for _, c := range list { + c.cleanup() + } +} + +// shutdown calls shutdown(2) on the pollable file f without taking it out +// of non-blocking mode, as f.Fd would. +func shutdown(f *os.File, how int) { + rc, err := f.SyscallConn() + if err != nil { + return + } + rc.Control(func(fd uintptr) { syscall.Shutdown(int(fd), how) }) +} + +// peerClosed reports whether the other end of the socketpair behind f has +// been closed outright, as opposed to shut down for writing: poll(2) +// reports POLLHUP for a closed peer and just POLLOUT for a half-closed one +// (true on Darwin and Linux), which is how the Go side tells close(2) from +// shutdown(SHUT_WR) on the C side once its reads hit EOF. A closed f +// counts as closed. +func peerClosed(f *os.File) bool { + rc, err := f.SyscallConn() + if err != nil { + return true + } + closed := true + if err := rc.Control(func(fd uintptr) { + fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}} + for { + n, err := unix.Poll(fds, 0) + if err == syscall.EINTR { + continue + } + closed = err != nil || (n > 0 && fds[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0) + return + } + }); err != nil { + return true + } + return closed +} + +// client is the state behind a client handle. +type client struct { + obj *object + + mu sync.Mutex + cl *tailcat.Client + keyFixed bool // the key has been read or generated; set_key is too late + started bool // the first ping, path or dial has happened + closed bool +} + +// configure runs f with c.mu held, refusing once the client has been +// used, since tailcat.Client reads its options at first use. +func (c *client) configure(f func() error) C.int { + c.mu.Lock() + var err error + switch { + case c.closed: + err = errClientClosed + case c.started: + err = errClientStarted + default: + err = f() + } + c.mu.Unlock() + return c.obj.recErr(err) +} + +// use marks the client as started and returns the tailcat.Client to call. +func (c *client) use() (*tailcat.Client, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, errClientClosed + } + c.started = true + c.keyFixed = true + return c.cl, nil +} + +//export TailcatClientNew +func TailcatClientNew(token *C.char) C.int { + blob := tailcat.ConnBlob(C.GoString(token)) + if _, err := tailcat.ParseConnBlob(blob); err != nil { + return 0 + } + o := &object{} + o.c = &client{obj: o, cl: &tailcat.Client{Server: blob}} + return newHandle(o) +} + +//export TailcatClientSetKey +func TailcatClientSetKey(cd C.int, keyJSON *C.char) C.int { + o, c := getClient(cd) + if c == nil { + return C.EBADF + } + pk, err := parsePrivateKey(C.GoString(keyJSON)) + if err != nil { + return o.recErr(err) + } + return c.configure(func() error { + if c.keyFixed { + return errKeyFixed + } + c.cl.Key = pk.Private + return nil + }) +} + +//export TailcatClientSetDERPMapURL +func TailcatClientSetDERPMapURL(cd C.int, url *C.char) C.int { + _, c := getClient(cd) + if c == nil { + return C.EBADF + } + u := C.GoString(url) + return c.configure(func() error { + c.cl.DERPMapURL = u + return nil + }) +} + +//export TailcatClientPublicKey +func TailcatClientPublicKey(cd C.int, buf *C.char, buflen C.size_t) C.int { + checkBuf("client_public_key", buf, buflen) + _, c := getClient(cd) + if c == nil { + *buf = '\x00' + return C.EBADF + } + c.mu.Lock() + c.keyFixed = true // PublicKey generates and pins the ephemeral key + pub := c.cl.PublicKey() + c.mu.Unlock() + return cstrOut(buf, buflen, pub.String()) +} + +//export TailcatClientPing +func TailcatClientPing(cd C.int, timeoutMs C.int, latencyMsOut *C.double) C.int { + o, c := getClient(cd) + if c == nil { + return C.EBADF + } + cl, err := c.use() + if err != nil { + return o.recErr(err) + } + ctx, cancel := timeoutContext(timeoutMs) + defer cancel() + res, err := cl.Ping(ctx) + if err != nil { + return o.recErr(err) + } + if latencyMsOut != nil { + *latencyMsOut = C.double(float64(res.Latency) / float64(time.Millisecond)) + } + return o.recErr(nil) +} + +//export TailcatClientPathJSON +func TailcatClientPathJSON(cd C.int, timeoutMs C.int, jsonOut **C.char) C.int { + if jsonOut == nil { + panic("client_path_json passed nil json_out") + } + *jsonOut = nil + o, c := getClient(cd) + if c == nil { + return C.EBADF + } + cl, err := c.use() + if err != nil { + return o.recErr(err) + } + ctx, cancel := timeoutContext(timeoutMs) + defer cancel() + res, err := cl.DiscoPing(ctx) + if err != nil { + return o.recErr(err) + } + b, err := json.Marshal(res) + if err != nil { + return o.recErr(err) + } + *jsonOut = C.CString(string(b)) + return o.recErr(nil) +} + +//export TailcatClientDial +func TailcatClientDial(cd C.int, port C.int, timeoutMs C.int, connOut *C.int) C.int { + if connOut == nil { + panic("client_dial passed nil conn_out") + } + o, c := getClient(cd) + if c == nil { + return C.EBADF + } + if port < 1 || port > 65535 { + return o.recErr(fmt.Errorf("invalid port %d", port)) + } + cl, err := c.use() + if err != nil { + return o.recErr(err) + } + ctx, cancel := timeoutContext(timeoutMs) + defer cancel() + nc, err := cl.DialTCPPort(ctx, uint16(port)) + if err != nil { + return o.recErr(err) + } + _, cfd, err := newConn(o, nc) + if err != nil { + nc.Close() + return o.recErr(err) + } + *connOut = C.int(cfd) + return o.recErr(nil) +} + +//export TailcatClientClose +func TailcatClientClose(cd C.int) C.int { + o := takeObject(cd, false) + if o == nil { + return C.EBADF + } + c := o.c + c.mu.Lock() + c.closed = true + cl := c.cl + c.mu.Unlock() + closeConnsOf(o) + if err := cl.Close(); err != nil { + logf := cl.Logf + if logf == nil { + logf = log.Printf + } + logf("libtailcat: client close: %v", err) + return -1 + } + return 0 +} + +//export TailcatKeyGenerate +func TailcatKeyGenerate(keyJSONOut **C.char) *C.char { + if keyJSONOut == nil { + panic("key_generate passed nil key_json_out") + } + *keyJSONOut = nil + pk := tailcat.NewPrivateKey() + pk.Public.RegionID = -1 // auto + b, err := json.MarshalIndent(pk, "", "\t") + if err != nil { + return cerr(err) + } + *keyJSONOut = C.CString(string(b)) + return nil +} + +//export TailcatKeyPublic +func TailcatKeyPublic(keyJSON *C.char, nodekeyOut **C.char) *C.char { + if nodekeyOut == nil { + panic("key_public passed nil nodekey_out") + } + *nodekeyOut = nil + pk, err := parsePrivateKey(C.GoString(keyJSON)) + if err != nil { + return cerr(err) + } + *nodekeyOut = C.CString(pk.Private.Public().String()) + return nil +} + +//export TailcatKeyToken +func TailcatKeyToken(keyJSON *C.char, tokenOut **C.char) *C.char { + if tokenOut == nil { + panic("key_token passed nil token_out") + } + *tokenOut = nil + pk, err := parsePrivateKey(C.GoString(keyJSON)) + if err != nil { + return cerr(err) + } + ci := pk.Public + switch { + case ci.RegionID == -1 && len(ci.Region) == 0: + return cerr(errors.New("the key's DERP region is auto (-1); the token is only known once the server starts")) + case ci.RegionID == 0 && len(ci.Region) == 0: + return cerr(errors.New("the key has no DERP region")) + } + // The public keys follow from the private key; the server announces + // exactly these. + ci.ServerPublic = tailcat.NodePublic{NodePublic: pk.Private.Public()} + ci.ServerDiscoPublic = tailcat.DiscoPublicForNode(pk.Private) + *tokenOut = C.CString(string(ci.ConnBlob())) + return nil +} + +//export TailcatTokenParse +func TailcatTokenParse(token *C.char, jsonOut **C.char) *C.char { + if jsonOut == nil { + panic("token_parse passed nil json_out") + } + *jsonOut = nil + v, err := tailcat.ParseConnBlobRaw(tailcat.ConnBlob(C.GoString(token))) + if err != nil { + return cerr(err) + } + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return cerr(err) + } + *jsonOut = C.CString(string(b)) + return nil +} + +//export TailcatTokenResolve +func TailcatTokenResolve(token, derpMapURL *C.char, timeoutMs C.int, tokenOut **C.char) *C.char { + if tokenOut == nil { + panic("token_resolve passed nil token_out") + } + *tokenOut = nil + ctx, cancel := timeoutContext(timeoutMs) + defer cancel() + var opts []any + if derpMapURL != nil { + if u := C.GoString(derpMapURL); u != "" { + opts = append(opts, tailcat.DERPMapURL(u)) + } + } + rb, err := tailcat.ConnBlob(C.GoString(token)).Resolve(ctx, opts...) + if err != nil { + return cerr(err) + } + *tokenOut = C.CString(string(rb)) + return nil +} diff --git a/libtailcat/libtailcat_test.go b/libtailcat/libtailcat_test.go new file mode 100644 index 000000000..aa8a12216 --- /dev/null +++ b/libtailcat/libtailcat_test.go @@ -0,0 +1,603 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build unix + +package main + +import ( + "bufio" + "encoding/json" + "os" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/tailscale/tailcat" + "tailscale.com/tstest/integration" + "tailscale.com/types/key" + "tailscale.com/types/logger" +) + +// The token from the README, and what "tailcat parse" shows for it. +const ( + readmeToken = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu" + readmeTokenKey = "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34" + readmeTokenRegn = 302 +) + +func mkLogger(t testing.TB, name string) logger.Logf { + return func(format string, args ...any) { + t.Helper() + if t.Failed() { + return + } + t.Logf(" ["+name+"] "+format, args...) + } +} + +// logPipes keeps the write ends of the log pipes alive for the life of +// the test binary: the Go side writes to their raw descriptor numbers, +// which must not be closed (and possibly reused) underneath it. +var logPipes []*os.File + +// logCapture collects the lines received through a logFD pipe. +type logCapture struct { + mu sync.Mutex + lines []string +} + +func (lc *logCapture) contains(sub string) bool { + lc.mu.Lock() + defer lc.mu.Unlock() + for _, l := range lc.lines { + if strings.Contains(l, sub) { + return true + } + } + return false +} + +// logFD returns a descriptor for tailcat_set_logfd whose lines are logged +// through t while the test runs, and collected in the returned capture. +func logFD(t *testing.T, name string) (cInt, *logCapture) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + logPipes = append(logPipes, w) + lc := new(logCapture) + var mu sync.Mutex + done := false + t.Cleanup(func() { + mu.Lock() + defer mu.Unlock() + done = true + }) + go func() { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 1<<20), 1<<20) + for sc.Scan() { + lc.mu.Lock() + lc.lines = append(lc.lines, sc.Text()) + lc.mu.Unlock() + mu.Lock() + if !done { + t.Logf(" [%s] %s", name, sc.Text()) + } + mu.Unlock() + } + }() + return cInt(w.Fd()), lc +} + +func errmsg(h cInt) string { + var buf [1024]cChar + TailcatErrmsg(h, &buf[0], cSize(len(buf))) + return goString(&buf[0]) +} + +// check fails the test if rc, returned by a call on handle h, isn't 0. +func check(t *testing.T, name string, h cInt, rc cInt) { + t.Helper() + if rc != 0 { + t.Fatalf("%s: rc=%d: %s", name, rc, errmsg(h)) + } +} + +// readBuf calls a buffer-output function and returns the string it wrote. +func readBuf(t *testing.T, name string, h cInt, fn func(*cChar, cSize) cInt) string { + t.Helper() + var buf [4096]cChar + check(t, name, h, fn(&buf[0], cSize(len(buf)))) + return goString(&buf[0]) +} + +// gostr converts and frees a malloc'd C string. +func gostr(p *cChar) string { + if p == nil { + return "" + } + defer cFree(p) + return goString(p) +} + +func writeAll(t *testing.T, fd cInt, s string) { + t.Helper() + b := []byte(s) + for len(b) > 0 { + n, err := syscall.Write(int(fd), b) + if err == syscall.EINTR { + continue + } + if err != nil { + t.Fatalf("write(%d): %v", fd, err) + } + b = b[n:] + } +} + +// readLine reads fd until a newline, failing on EOF or error. +func readLine(t *testing.T, fd cInt) string { + t.Helper() + var got []byte + var b [256]byte + for { + n, err := syscall.Read(int(fd), b[:]) + if err == syscall.EINTR { + continue + } + if err != nil { + t.Fatalf("read(%d): %v", fd, err) + } + if n == 0 { + t.Fatalf("read(%d): EOF before a newline; got %q", fd, got) + } + got = append(got, b[:n]...) + if got[len(got)-1] == '\n' { + return string(got) + } + } +} + +// expectEOF reads fd, expecting it to return 0 bytes. +func expectEOF(t *testing.T, fd cInt) { + t.Helper() + var b [256]byte + for { + n, err := syscall.Read(int(fd), b[:]) + if err == syscall.EINTR { + continue + } + if err != nil { + t.Fatalf("read(%d): %v; want EOF", fd, err) + } + if n == 0 { + return + } + t.Fatalf("read(%d) = %q; want EOF", fd, b[:n]) + } +} + +// accept runs TailcatAccept on l with a timeout. +func accept(t *testing.T, l cInt, timeout time.Duration) cInt { + t.Helper() + type result struct { + fd cInt + rc cInt + } + ch := make(chan result, 1) + go func() { + var fd cInt + rc := TailcatAccept(l, &fd) + ch <- result{fd, rc} + }() + select { + case r := <-ch: + if r.rc != 0 { + t.Fatalf("accept on %d: rc=%d", l, r.rc) + } + return r.fd + case <-time.After(timeout): + t.Fatalf("accept on %d: no connection after %v", l, timeout) + return 0 + } +} + +func connInfo(t *testing.T, l, c cInt) (remote string, localPort int) { + t.Helper() + var buf [256]cChar + var port cInt + if rc := TailcatConnInfo(l, c, &buf[0], cSize(len(buf)), &port); rc != 0 { + t.Fatalf("conn_info(%d, %d): rc=%d", l, c, rc) + } + return goString(&buf[0]), int(port) +} + +// waitFor polls cond for up to timeout. +func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for %s", what) + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestEndToEnd(t *testing.T) { + dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") + reg := dm.Regions[1] + if reg == nil { + t.Fatal("no region 1 in derpmap") + } + + // Server. + sd := TailcatServerNew() + serverLogFD, serverLog := logFD(t, "server") + check(t, "set_logfd", sd, TailcatSetLogFD(sd, serverLogFD)) + setServerRegionForTest(sd, reg) + var l80, lAny cInt + check(t, "listen 80", sd, TailcatServerListen(sd, 80, &l80)) + if rc := TailcatServerListen(sd, 80, &lAny); rc != -1 || errmsg(sd) == "" { + t.Fatalf("second listen on port 80: rc=%d, errmsg=%q; want -1 with a message", rc, errmsg(sd)) + } + if rc := TailcatServerToken(sd, new(cChar), 1); rc != -1 { + t.Fatalf("token before start: rc=%d; want -1", rc) + } + check(t, "start", sd, TailcatServerStart(sd)) + if rc := TailcatServerStart(sd); rc != -1 { + t.Fatalf("second start: rc=%d; want -1", rc) + } + check(t, "listen 0", sd, TailcatServerListen(sd, 0, &lAny)) + token := readBuf(t, "token", sd, func(b *cChar, n cSize) cInt { return TailcatServerToken(sd, b, n) }) + serverKey := readBuf(t, "public_key", sd, func(b *cChar, n cSize) cInt { return TailcatServerPublicKey(sd, b, n) }) + t.Logf("server token %s, key %s", token, serverKey) + if !strings.HasPrefix(token, "tc") || !strings.HasPrefix(serverKey, "nodekey:") { + t.Fatalf("malformed token %q or key %q", token, serverKey) + } + var small [4]cChar + if rc := TailcatServerToken(sd, &small[0], cSize(len(small))); rc != cERANGE || small[3] != 0 || goString(&small[0]) != token[:3] { + t.Fatalf("token into a small buffer: rc=%d, got %q; want ERANGE and a NUL-terminated prefix", rc, goString(&small[0])) + } + var status *cChar + check(t, "status_json", sd, TailcatServerStatusJSON(sd, &status)) + if s := gostr(status); !json.Valid([]byte(s)) { + t.Fatalf("status_json returned invalid JSON: %q", s) + } + + // Client. + ctoken := cString(token) + defer cFree(ctoken) + cd := TailcatClientNew(ctoken) + if cd == 0 { + t.Fatal("client_new returned 0 for the server's token") + } + clientLogFD, _ := logFD(t, "client") + check(t, "client set_logfd", cd, TailcatSetLogFD(cd, clientLogFD)) + clientKey := readBuf(t, "client public_key", cd, func(b *cChar, n cSize) cInt { return TailcatClientPublicKey(cd, b, n) }) + t.Logf("client key %s", clientKey) + + // Handles of the wrong kind. + if rc := TailcatServerStart(cd); rc != cEBADF { + t.Fatalf("server_start on a client handle: rc=%d; want EBADF", rc) + } + if rc := TailcatClientPing(sd, 100, nil); rc != cEBADF { + t.Fatalf("client_ping on a server handle: rc=%d; want EBADF", rc) + } + + // A server with an allowlist ignores clients not on it: allow some + // other key first and check that a ping gets no answer. + other := cString(key.NewNode().Public().String()) + check(t, "allow other", sd, TailcatServerAllowClient(sd, other)) + cFree(other) + var latency cDouble + if rc := TailcatClientPing(cd, 300, &latency); rc != -1 { + t.Fatalf("ping from a disallowed client: rc=%d; want -1", rc) + } + t.Logf("disallowed ping: %s", errmsg(cd)) + ckey := cString(clientKey) + check(t, "allow client", sd, TailcatServerAllowClient(sd, ckey)) + cFree(ckey) + // The server finishes connecting to the relay in the background + // after start, and a ping sends a single meow that a server not yet + // on the relay never sees, so retry until one gets through. + deadline := time.Now().Add(30 * time.Second) + for { + if rc := TailcatClientPing(cd, 2000, &latency); rc == 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("ping: %s", errmsg(cd)) + } + t.Logf("ping: %s; retrying", errmsg(cd)) + } + if latency <= 0 { + t.Fatalf("ping latency = %v ms; want > 0", latency) + } + t.Logf("ping latency %.2f ms", float64(latency)) + var path *cChar + check(t, "path_json", cd, TailcatClientPathJSON(cd, 10000, &path)) + t.Logf("path: %s", gostr(path)) + + // Now that the server is known to be reachable, a client that isn't + // allowed must be ignored: its ping times out and the server logs + // the rejection. + cd2 := TailcatClientNew(ctoken) + if cd2 == 0 { + t.Fatal("client_new returned 0 for the server's token") + } + check(t, "client2 set_logfd", cd2, TailcatSetLogFD(cd2, -1)) + key2 := readBuf(t, "client2 public_key", cd2, func(b *cChar, n cSize) cInt { return TailcatClientPublicKey(cd2, b, n) }) + if rc := TailcatClientPing(cd2, 3000, nil); rc != -1 { + t.Fatalf("ping from a disallowed client: rc=%d; want -1", rc) + } + if want := "ignoring meow from " + key2; !serverLog.contains(want) { + t.Fatalf("server log lacks %q after a disallowed ping", want) + } + check(t, "client2 close", cd2, TailcatClientClose(cd2)) + + // Dial, accept, exchange data both ways. + var c1 cInt + check(t, "dial 80", cd, TailcatClientDial(cd, 80, 10000, &c1)) + writeAll(t, c1, "hello\n") + s1 := accept(t, l80, 10*time.Second) + if got := readLine(t, s1); got != "hello\n" { + t.Fatalf("server read %q; want hello", got) + } + remote, port := connInfo(t, l80, s1) + if remote == "" || port != 80 { + t.Fatalf("conn_info = %q, %d; want a remote address and port 80", remote, port) + } + t.Logf("accepted from %s on port %d", remote, port) + writeAll(t, s1, "world\n") + if got := readLine(t, c1); got != "world\n" { + t.Fatalf("client read %q; want world", got) + } + + // Half-close: the server sees EOF but can still answer. + if err := syscall.Shutdown(int(c1), syscall.SHUT_WR); err != nil { + t.Fatal(err) + } + expectEOF(t, s1) + writeAll(t, s1, "bye\n") + if got := readLine(t, c1); got != "bye\n" { + t.Fatalf("client read %q after half-close; want bye", got) + } + syscall.Close(int(s1)) + syscall.Close(int(c1)) + + // The catch-all listener gets the ports nobody else listens on. + var c2 cInt + check(t, "dial 8080", cd, TailcatClientDial(cd, 8080, 10000, &c2)) + s2 := accept(t, lAny, 10*time.Second) + if _, port := connInfo(t, lAny, s2); port != 8080 { + t.Fatalf("catch-all conn_info port = %d; want 8080", port) + } + syscall.Close(int(s2)) + syscall.Close(int(c2)) + + // Without the catch-all, an unregistered port is refused. + syscall.Close(int(lAny)) + _, s := getServer(sd) + waitFor(t, "the catch-all listener to unregister", 5*time.Second, func() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.ports[0] == nil + }) + var c3 cInt + if rc := TailcatClientDial(cd, 9999, 5000, &c3); rc == 0 { + // The RST may land after the dial returned; the first read + // must then fail promptly. + done := make(chan struct{}) + go func() { + defer close(done) + var b [16]byte + n, err := syscall.Read(int(c3), b[:]) + if n > 0 && err == nil { + t.Errorf("read on a connection to an unregistered port got %q", b[:n]) + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("read on a connection to an unregistered port didn't fail within 5s") + } + syscall.Close(int(c3)) + } else { + t.Logf("dial to an unregistered port: rc=%d: %s", rc, errmsg(cd)) + } + + // Keys and tokens. + var keyJSON, nodekey, tok, parsed *cChar + if e := TailcatKeyGenerate(&keyJSON); e != nil { + t.Fatalf("key_generate: %s", gostr(e)) + } + kj := gostr(keyJSON) + var pk tailcat.PrivateKey + if err := json.Unmarshal([]byte(kj), &pk); err != nil { + t.Fatalf("key_generate JSON: %v\n%s", err, kj) + } + if pk.Public.RegionID != -1 || pk.Private.IsZero() { + t.Fatalf("key_generate: RegionID=%d, private zero=%v; want -1 and a key", pk.Public.RegionID, pk.Private.IsZero()) + } + ckj := cString(kj) + if e := TailcatKeyPublic(ckj, &nodekey); e != nil { + t.Fatalf("key_public: %s", gostr(e)) + } + if got, want := gostr(nodekey), pk.Private.Public().String(); got != want { + t.Fatalf("key_public = %q; want %q", got, want) + } + if e := TailcatKeyToken(ckj, &tok); e == nil || tok != nil { + t.Fatalf("key_token on an auto-region key succeeded: %q", gostr(tok)) + } else { + t.Logf("key_token on an auto-region key: %s", gostr(e)) + } + cFree(ckj) + pk.Public.RegionID = readmeTokenRegn + fixed, err := json.Marshal(pk) + if err != nil { + t.Fatal(err) + } + cfixed := cString(string(fixed)) + if e := TailcatKeyToken(cfixed, &tok); e != nil { + t.Fatalf("key_token on a fixed-region key: %s", gostr(e)) + } + cFree(cfixed) + ci, err := tailcat.ParseConnBlob(tailcat.ConnBlob(gostr(tok))) + if err != nil { + t.Fatalf("parsing key_token output: %v", err) + } + if ci.RegionID != readmeTokenRegn || ci.ServerPublic.NodePublic != pk.Private.Public() || ci.ServerDiscoPublic.IsZero() { + t.Fatalf("key_token output parsed to %+v", ci) + } + + creadme := cString(readmeToken) + if e := TailcatTokenParse(creadme, &parsed); e != nil { + t.Fatalf("token_parse: %s", gostr(e)) + } + cFree(creadme) + var fields struct { + ServerPublic string + RegionID int + } + pj := gostr(parsed) + if err := json.Unmarshal([]byte(pj), &fields); err != nil { + t.Fatalf("token_parse JSON: %v\n%s", err, pj) + } + if fields.ServerPublic != readmeTokenKey || fields.RegionID != readmeTokenRegn { + t.Fatalf("token_parse = %+v; want key %s and region %d", fields, readmeTokenKey, readmeTokenRegn) + } + cbad := cString("nope") + if e := TailcatTokenParse(cbad, &parsed); e == nil { + t.Fatal("token_parse accepted a malformed token") + } else { + cFree(e) + } + if h := TailcatClientNew(cbad); h != 0 { + t.Fatalf("client_new on a malformed token = %d; want 0", h) + } + cFree(cbad) + cempty := cString("{}") + if e := TailcatKeyPublic(cempty, &nodekey); e == nil { + t.Fatal("key_public accepted an empty key") + } else { + cFree(e) + } + cFree(cempty) + + // Close everything and wait for the goroutines to let go. + check(t, "client_close", cd, TailcatClientClose(cd)) + if rc := TailcatClientClose(cd); rc != cEBADF { + t.Fatalf("second client_close: rc=%d; want EBADF", rc) + } + check(t, "server_close", sd, TailcatServerClose(sd)) + if rc := TailcatServerClose(sd); rc != cEBADF { + t.Fatalf("second server_close: rc=%d; want EBADF", rc) + } + // The server closed the Go end of l80; its C end now reads EOF. + expectEOF(t, l80) + syscall.Close(int(l80)) + + waitFor(t, "connection and listener cleanup", 5*time.Second, func() bool { + conns.mu.Lock() + nc := len(conns.m) + conns.mu.Unlock() + listeners.mu.Lock() + nl := len(listeners.m) + listeners.mu.Unlock() + return nc == 0 && nl == 0 + }) + objects.mu.Lock() + rem := len(objects.m) + objects.mu.Unlock() + if rem != 0 { + t.Fatalf("%d handles remain after close", rem) + } +} + +// TestServerCloseBeforeStart checks that a server that never started can +// be closed, and that its listeners go with it. +func TestServerCloseBeforeStart(t *testing.T) { + sd := TailcatServerNew() + check(t, "set_logfd", sd, TailcatSetLogFD(sd, -1)) + var l cInt + check(t, "listen", sd, TailcatServerListen(sd, 443, &l)) + check(t, "close", sd, TailcatServerClose(sd)) + if rc := TailcatServerClose(sd); rc != cEBADF { + t.Fatalf("second close: rc=%d; want EBADF", rc) + } + if rc := TailcatErrmsg(sd, new(cChar), 1); rc != cEBADF { + t.Fatalf("errmsg on a closed handle: rc=%d; want EBADF", rc) + } + expectEOF(t, l) + var fd cInt + if rc := TailcatAccept(l, &fd); rc != cEBADF { + t.Fatalf("accept on a closed server's listener: rc=%d; want EBADF", rc) + } + syscall.Close(int(l)) +} + +// TestServerConfig checks the configuration calls that need no network. +func TestServerConfig(t *testing.T) { + sd := TailcatServerNew() + defer TailcatServerClose(sd) + _, s := getServer(sd) + + if rc := TailcatServerSetRegionID(sd, -5); rc != -1 { + t.Fatalf("set_region_id(-5): rc=%d; want -1", rc) + } + check(t, "set_region_id", sd, TailcatServerSetRegionID(sd, 302)) + if s.ci.RegionID != 302 { + t.Fatalf("RegionID = %d; want 302", s.ci.RegionID) + } + check(t, "set_region_id auto", sd, TailcatServerSetRegionID(sd, 0)) + if s.ci.RegionID != -1 { + t.Fatalf("RegionID = %d; want -1 for auto", s.ci.RegionID) + } + hosts := cString(" derp1.example.com, derp2.example.com ") + check(t, "set_relay_hosts", sd, TailcatServerSetRelayHosts(sd, hosts)) + cFree(hosts) + if len(s.ci.Region) != 1 || len(s.ci.Region[0].Nodes) != 2 || s.ci.Region[0].Nodes[0].HostName != "derp1.example.com" { + t.Fatalf("relay hosts region = %+v", s.ci.Region) + } + empty := cString(",") + if rc := TailcatServerSetRelayHosts(sd, empty); rc != -1 { + t.Fatalf("set_relay_hosts(\",\"): rc=%d; want -1", rc) + } + cFree(empty) + + // A key file sets the private key and the region. + pk := tailcat.NewPrivateKey() + pk.Public.RegionID = 7 + js, err := json.Marshal(pk) + if err != nil { + t.Fatal(err) + } + cjs := cString(string(js)) + check(t, "set_key", sd, TailcatServerSetKey(sd, cjs)) + cFree(cjs) + if !s.priv.Equal(pk.Private) || s.ci.RegionID != 7 || len(s.ci.Region) != 0 { + t.Fatalf("after set_key: RegionID=%d, Region=%v, key matches=%v", s.ci.RegionID, s.ci.Region, s.priv.Equal(pk.Private)) + } + if got := readBuf(t, "public_key", sd, func(b *cChar, n cSize) cInt { return TailcatServerPublicKey(sd, b, n) }); got != pk.Private.Public().String() { + t.Fatalf("public_key = %q; want %q", got, pk.Private.Public().String()) + } + bad := cString("{}") + if rc := TailcatServerSetKey(sd, bad); rc != -1 { + t.Fatalf("set_key(\"{}\"): rc=%d; want -1", rc) + } + cFree(bad) + if rc := TailcatServerAllowClient(sd, bad); rc != -1 { + t.Fatalf("allow_client(\"{}\"): rc=%d; want -1", rc) + } + if rc := TailcatServerListen(sd, 70000, new(cInt)); rc != -1 { + t.Fatalf("listen(70000): rc=%d; want -1", rc) + } + if rc := TailcatServerStart(9999); rc != cEBADF { + t.Fatalf("start on a bogus handle: rc=%d; want EBADF", rc) + } +} diff --git a/libtailcat/script/clangwrap-ios-sim-arm.sh b/libtailcat/script/clangwrap-ios-sim-arm.sh new file mode 100755 index 000000000..9ce446cee --- /dev/null +++ b/libtailcat/script/clangwrap-ios-sim-arm.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Copyright (c) Tailscale Inc & contributors +# SPDX-License-Identifier: BSD-3-Clause + +# cgo CC wrapper for the iOS simulator on Apple silicon (arm64). + +SDK=iphonesimulator +PLATFORM=ios-simulator + +CLANGARCH=arm64 + +SDK_PATH=`xcrun --sdk $SDK --show-sdk-path` + +# cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang. +CLANG=`xcrun --sdk $SDK --find clang` + +exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=17.0 "$@" diff --git a/libtailcat/script/clangwrap-ios-sim-x86.sh b/libtailcat/script/clangwrap-ios-sim-x86.sh new file mode 100755 index 000000000..824a7db9d --- /dev/null +++ b/libtailcat/script/clangwrap-ios-sim-x86.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Copyright (c) Tailscale Inc & contributors +# SPDX-License-Identifier: BSD-3-Clause + +# cgo CC wrapper for the iOS simulator on Intel Macs (x86_64). + +SDK=iphonesimulator +PLATFORM=ios-simulator + +CLANGARCH=x86_64 + +SDK_PATH=`xcrun --sdk $SDK --show-sdk-path` + +# cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang. +CLANG=`xcrun --sdk $SDK --find clang` + +exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=17.0 "$@" diff --git a/libtailcat/script/clangwrap-ios.sh b/libtailcat/script/clangwrap-ios.sh new file mode 100755 index 000000000..cd9d7b90a --- /dev/null +++ b/libtailcat/script/clangwrap-ios.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Copyright (c) Tailscale Inc & contributors +# SPDX-License-Identifier: BSD-3-Clause + +# cgo CC wrapper for iOS devices (arm64). + +SDK=iphoneos +PLATFORM=ios + +CLANGARCH="arm64" + +SDK_PATH=`xcrun --sdk $SDK --show-sdk-path` + +# cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang. +CLANG=`xcrun --sdk $SDK --find clang` + +exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=17.0 "$@" diff --git a/libtailcat/script/clangwrap-macos-x86.sh b/libtailcat/script/clangwrap-macos-x86.sh new file mode 100755 index 000000000..ee1f25051 --- /dev/null +++ b/libtailcat/script/clangwrap-macos-x86.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Copyright (c) Tailscale Inc & contributors +# SPDX-License-Identifier: BSD-3-Clause + +# cgo CC wrapper for cross-compiling the macOS x86_64 slice on any Mac. +# MACOS_TARGET, exported by the Makefile, is the minimum macOS version. + +SDK=macosx + +SDK_PATH=`xcrun --sdk $SDK --show-sdk-path` + +# cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang. +CLANG=`xcrun --sdk $SDK --find clang` + +exec "$CLANG" -arch x86_64 -target x86_64-apple-macos${MACOS_TARGET:-14.0} -isysroot "$SDK_PATH" "$@" diff --git a/libtailcat/sigpipe_darwin.go b/libtailcat/sigpipe_darwin.go new file mode 100644 index 000000000..494133f22 --- /dev/null +++ b/libtailcat/sigpipe_darwin.go @@ -0,0 +1,18 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build darwin + +package main + +import "syscall" + +// setNoSigPipe makes writes to the socket fd fail with EPIPE instead of +// raising SIGPIPE once the other end is gone. The Go runtime already +// handles SIGPIPE on its own threads, but a SIGPIPE raised on a non-Go +// thread, as when the C side writes to a dead connection, would kill the +// process. Darwin has a per-socket option for this; it is set on both +// ends of every socketpair handed out. +func setNoSigPipe(fd int) { + syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_NOSIGPIPE, 1) +} diff --git a/libtailcat/sigpipe_other.go b/libtailcat/sigpipe_other.go new file mode 100644 index 000000000..79318830a --- /dev/null +++ b/libtailcat/sigpipe_other.go @@ -0,0 +1,11 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build unix && !darwin + +package main + +// setNoSigPipe is a no-op here: only Darwin has a per-socket SO_NOSIGPIPE +// option. Non-Go threads writing to a dead connection on other platforms +// should ignore SIGPIPE themselves (see sigpipe_darwin.go). +func setNoSigPipe(fd int) {} diff --git a/libtailcat/tailcat.c b/libtailcat/tailcat.c new file mode 100644 index 000000000..7ed0e14be --- /dev/null +++ b/libtailcat/tailcat.c @@ -0,0 +1,166 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// The C entry points of libtailcat. Each forwards to the Go function of the +// same name (Tailcat prefix, exported from libtailcat.go with //export), +// casting away the const that the cgo-generated declarations lack. The Go +// symbols are declared here by hand rather than by including _cgo_export.h, +// which is a build artifact, so this file is the const-correct view of them. + +#include "tailcat.h" + +// Functions exported by Go. +extern int TailcatErrmsg(int h, char* buf, size_t buflen); +extern int TailcatSetLogFD(int h, int fd); + +extern int TailcatServerNew(void); +extern int TailcatServerSetKey(int sd, char* keyJSON); +extern int TailcatServerSetRegionID(int sd, int regionID); +extern int TailcatServerSetRelayHosts(int sd, char* hosts); +extern int TailcatServerSetDERPMapURL(int sd, char* url); +extern int TailcatServerSetEmbedRelay(int sd, int embed); +extern int TailcatServerAllowClient(int sd, char* nodekey); +extern int TailcatServerListen(int sd, int port, int* listenerOut); +extern int TailcatServerStart(int sd); +extern int TailcatServerToken(int sd, char* buf, size_t buflen); +extern int TailcatServerPublicKey(int sd, char* buf, size_t buflen); +extern int TailcatServerStatusJSON(int sd, char** jsonOut); +extern int TailcatServerClose(int sd); + +extern int TailcatAccept(int l, int* connOut); +extern int TailcatConnInfo(int l, int c, char* remoteBuf, size_t remoteBuflen, int* localPortOut); + +extern int TailcatClientNew(char* token); +extern int TailcatClientSetKey(int cd, char* keyJSON); +extern int TailcatClientSetDERPMapURL(int cd, char* url); +extern int TailcatClientPublicKey(int cd, char* buf, size_t buflen); +extern int TailcatClientPing(int cd, int timeoutMs, double* latencyMsOut); +extern int TailcatClientPathJSON(int cd, int timeoutMs, char** jsonOut); +extern int TailcatClientDial(int cd, int port, int timeoutMs, int* connOut); +extern int TailcatClientClose(int cd); + +extern char* TailcatKeyGenerate(char** keyJSONOut); +extern char* TailcatKeyPublic(char* keyJSON, char** nodekeyOut); +extern char* TailcatKeyToken(char* keyJSON, char** tokenOut); +extern char* TailcatTokenParse(char* token, char** jsonOut); +extern char* TailcatTokenResolve(char* token, char* derpmapURL, int timeoutMs, char** tokenOut); + +int tailcat_errmsg(tailcat_handle h, char* buf, size_t buflen) { + return TailcatErrmsg(h, buf, buflen); +} + +int tailcat_set_logfd(tailcat_handle h, int fd) { + return TailcatSetLogFD(h, fd); +} + +tailcat_handle tailcat_server_new(void) { + return TailcatServerNew(); +} + +int tailcat_server_set_key(tailcat_handle sd, const char* key_json) { + return TailcatServerSetKey(sd, (char*)key_json); +} + +int tailcat_server_set_region_id(tailcat_handle sd, int region_id) { + return TailcatServerSetRegionID(sd, region_id); +} + +int tailcat_server_set_relay_hosts(tailcat_handle sd, const char* hosts) { + return TailcatServerSetRelayHosts(sd, (char*)hosts); +} + +int tailcat_server_set_derpmap_url(tailcat_handle sd, const char* url) { + return TailcatServerSetDERPMapURL(sd, (char*)url); +} + +int tailcat_server_set_embed_relay(tailcat_handle sd, int embed) { + return TailcatServerSetEmbedRelay(sd, embed); +} + +int tailcat_server_allow_client(tailcat_handle sd, const char* nodekey) { + return TailcatServerAllowClient(sd, (char*)nodekey); +} + +int tailcat_server_listen(tailcat_handle sd, int port, tailcat_listener* listener_out) { + return TailcatServerListen(sd, port, (int*)listener_out); +} + +int tailcat_server_start(tailcat_handle sd) { + return TailcatServerStart(sd); +} + +int tailcat_server_token(tailcat_handle sd, char* buf, size_t buflen) { + return TailcatServerToken(sd, buf, buflen); +} + +int tailcat_server_public_key(tailcat_handle sd, char* buf, size_t buflen) { + return TailcatServerPublicKey(sd, buf, buflen); +} + +int tailcat_server_status_json(tailcat_handle sd, char** json_out) { + return TailcatServerStatusJSON(sd, json_out); +} + +int tailcat_server_close(tailcat_handle sd) { + return TailcatServerClose(sd); +} + +int tailcat_accept(tailcat_listener l, tailcat_conn* conn_out) { + return TailcatAccept(l, (int*)conn_out); +} + +int tailcat_conn_info(tailcat_listener l, tailcat_conn c, char* remote_buf, size_t remote_buflen, int* local_port_out) { + return TailcatConnInfo(l, c, remote_buf, remote_buflen, local_port_out); +} + +tailcat_handle tailcat_client_new(const char* token) { + return TailcatClientNew((char*)token); +} + +int tailcat_client_set_key(tailcat_handle cd, const char* key_json) { + return TailcatClientSetKey(cd, (char*)key_json); +} + +int tailcat_client_set_derpmap_url(tailcat_handle cd, const char* url) { + return TailcatClientSetDERPMapURL(cd, (char*)url); +} + +int tailcat_client_public_key(tailcat_handle cd, char* buf, size_t buflen) { + return TailcatClientPublicKey(cd, buf, buflen); +} + +int tailcat_client_ping(tailcat_handle cd, int timeout_ms, double* latency_ms_out) { + return TailcatClientPing(cd, timeout_ms, latency_ms_out); +} + +int tailcat_client_path_json(tailcat_handle cd, int timeout_ms, char** json_out) { + return TailcatClientPathJSON(cd, timeout_ms, json_out); +} + +int tailcat_client_dial(tailcat_handle cd, int port, int timeout_ms, tailcat_conn* conn_out) { + return TailcatClientDial(cd, port, timeout_ms, (int*)conn_out); +} + +int tailcat_client_close(tailcat_handle cd) { + return TailcatClientClose(cd); +} + +char* tailcat_key_generate(char** key_json_out) { + return TailcatKeyGenerate(key_json_out); +} + +char* tailcat_key_public(const char* key_json, char** nodekey_out) { + return TailcatKeyPublic((char*)key_json, nodekey_out); +} + +char* tailcat_key_token(const char* key_json, char** token_out) { + return TailcatKeyToken((char*)key_json, token_out); +} + +char* tailcat_token_parse(const char* token, char** json_out) { + return TailcatTokenParse((char*)token, json_out); +} + +char* tailcat_token_resolve(const char* token, const char* derpmap_url, int timeout_ms, char** token_out) { + return TailcatTokenResolve((char*)token, (char*)derpmap_url, timeout_ms, token_out); +} From 2fa637049c1748aea8ef3c7c8252b35280cc19aa Mon Sep 17 00:00:00 2001 From: Josue Diaz Flores Date: Tue, 1 Sep 2026 22:00:20 -0700 Subject: [PATCH 2/6] swift: add TailcatKit Swift package, demo and tests --- README.md | 9 + swift/.gitignore | 6 + swift/Package.swift | 36 ++ swift/README.md | 139 ++++++++ swift/Sources/TailcatDemo/main.swift | 158 +++++++++ swift/Sources/TailcatKit/Blocking.swift | 43 +++ swift/Sources/TailcatKit/Connection.swift | 281 +++++++++++++++ .../Sources/TailcatKit/ConnectionToken.swift | 187 ++++++++++ swift/Sources/TailcatKit/Identity.swift | 94 +++++ swift/Sources/TailcatKit/Listener.swift | 218 ++++++++++++ swift/Sources/TailcatKit/LogSink.swift | 45 +++ swift/Sources/TailcatKit/Relay.swift | 59 ++++ swift/Sources/TailcatKit/TailcatClient.swift | 198 +++++++++++ swift/Sources/TailcatKit/TailcatError.swift | 139 ++++++++ swift/Sources/TailcatKit/TailcatServer.swift | 197 +++++++++++ .../Tests/TailcatKitTests/EndToEndTests.swift | 106 ++++++ .../Tests/TailcatKitTests/IdentityTests.swift | 66 ++++ .../Tests/TailcatKitTests/OfflineTests.swift | 330 ++++++++++++++++++ swift/Tests/TailcatKitTests/TokenTests.swift | 84 +++++ 19 files changed, 2395 insertions(+) create mode 100644 swift/.gitignore create mode 100644 swift/Package.swift create mode 100644 swift/README.md create mode 100644 swift/Sources/TailcatDemo/main.swift create mode 100644 swift/Sources/TailcatKit/Blocking.swift create mode 100644 swift/Sources/TailcatKit/Connection.swift create mode 100644 swift/Sources/TailcatKit/ConnectionToken.swift create mode 100644 swift/Sources/TailcatKit/Identity.swift create mode 100644 swift/Sources/TailcatKit/Listener.swift create mode 100644 swift/Sources/TailcatKit/LogSink.swift create mode 100644 swift/Sources/TailcatKit/Relay.swift create mode 100644 swift/Sources/TailcatKit/TailcatClient.swift create mode 100644 swift/Sources/TailcatKit/TailcatError.swift create mode 100644 swift/Sources/TailcatKit/TailcatServer.swift create mode 100644 swift/Tests/TailcatKitTests/EndToEndTests.swift create mode 100644 swift/Tests/TailcatKitTests/IdentityTests.swift create mode 100644 swift/Tests/TailcatKitTests/OfflineTests.swift create mode 100644 swift/Tests/TailcatKitTests/TokenTests.swift diff --git a/README.md b/README.md index e85caa8d1..87b3e9ebb 100644 --- a/README.md +++ b/README.md @@ -652,3 +652,12 @@ Go module client of the tailscale.com repo instead of a fork of it. It was open sourced August 2026 at the [TailscaleUp conference](https://tailscale.com/tailscaleup). + +## Swift / C bindings + +[`libtailcat/`](./libtailcat/) exports a small C API over the Go library, +built as static archives for macOS, iOS and the iOS simulator and packaged +as `CTailcat.xcframework`, and [`swift/`](./swift/) wraps it as the +`TailcatKit` Swift package (Swift 6, async/await servers, clients and +connections) with a demo tool and tests. See their READMEs to build and +use them. diff --git a/swift/.gitignore b/swift/.gitignore new file mode 100644 index 000000000..5031977b4 --- /dev/null +++ b/swift/.gitignore @@ -0,0 +1,6 @@ +# Built by `make xcframework` in ../libtailcat. +/CTailcat.xcframework +# SwiftPM, xcodebuild (-derivedDataPath build) and Xcode state. +/.build +/build +/.swiftpm diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 000000000..de1305988 --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 6.0 +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import PackageDescription + +let package = Package( + name: "TailcatKit", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library(name: "TailcatKit", targets: ["TailcatKit"]), + .executable(name: "tailcat-demo", targets: ["tailcat-demo"]), + ], + targets: [ + // The Go library as a static archive per platform, built by + // `make xcframework` in ../libtailcat. + .binaryTarget(name: "CTailcat", path: "CTailcat.xcframework"), + .target( + name: "TailcatKit", + dependencies: ["CTailcat"], + linkerSettings: [ + // The Go runtime and its net package need these on Darwin. + .linkedFramework("CoreFoundation"), + .linkedFramework("Security"), + .linkedLibrary("resolv"), + ] + ), + .executableTarget( + name: "tailcat-demo", + dependencies: ["TailcatKit"], + path: "Sources/TailcatDemo" + ), + .testTarget(name: "TailcatKitTests", dependencies: ["TailcatKit"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/swift/README.md b/swift/README.md new file mode 100644 index 000000000..14c15349c --- /dev/null +++ b/swift/README.md @@ -0,0 +1,139 @@ +# TailcatKit + +A Swift package wrapping [libtailcat](../libtailcat/README.md), the C API +over the [tailcat](../README.md) Go library, in async/await actors for +macOS 14+ and iOS 17+. A `TailcatServer` announces a connection token; +a `TailcatClient` holding the token dials TCP ports on it; both ends +handle bytes through `Connection`. Swift 6 language mode, strict +concurrency, no Combine. + +## Building + +Build the Go archives first (Go and Xcode with the iOS SDK required): + +```sh +cd ../libtailcat && make xcframework # writes ./CTailcat.xcframework +cd ../swift +swift build # the TailcatKit library and tailcat-demo +swift build -c release +swift test # offline tests +TAILCAT_E2E=1 swift test # plus a server/client round trip over the public relays +``` + +The package is `swift-tools-version: 6.0`; it declares the binary +target `CTailcat` (the xcframework with slices for macOS arm64/x86_64, +iOS arm64 and the iOS simulator arm64/x86_64), the library `TailcatKit`, +the executable `tailcat-demo` and the tests. Add it to an app as a local +or remote package dependency; the `CoreFoundation` and `Security` +frameworks and `libresolv` the Go runtime needs are linked by the +package. + +## Usage + +A server that echoes every connection to port 8080: + +```swift +import TailcatKit + +let server = try TailcatServer(configuration: .init(relay: .automatic), logger: DefaultLogger()) +let listener = try await server.listen(on: 8080) // before or after start; 0 is the catch-all +let token = try await server.start() // blocks off-thread: DERP map, latency check +print("connect with: \(token)") + +for try await connection in listener.connections { + Task { + print("from \(connection.remoteAddress ?? "?") on port \(connection.localPort ?? 0)") + for try await chunk in connection.incoming { // until the peer's EOF + try await connection.send(chunk) + } + connection.close() + } +} +``` + +A client: + +```swift +let client = try TailcatClient(token: token) +let rtt = try await client.ping() // brings the tunnel up; retry on .timeout right after the server started +let path = try await client.path() // direct endpoint or relay region +let connection = try await client.connect(port: 8080) +try await connection.send(Data("hello\n".utf8)) +connection.closeWrite() // half-close: the server reads EOF +let reply = try await connection.receive() // empty Data at EOF +connection.close() +await client.close() +``` + +Keys and tokens need no server or client: + +```swift +let identity = try Identity.generate() // a private key; keep it in the Keychain +identity.publicKey // "nodekey:", for TailcatServer.allow +let token = ConnectionToken(rawValue: "tc...")! +let info = try token.parse() // server key, region ID or relay hosts +let long = try await token.resolved() // self-contained form, relay details embedded +``` + +Restrict a server to known clients with `ServerConfiguration.allowedClients` +or `TailcatServer.allow(_:)`, and give clients an `Identity` so their +public key is stable. With a saved identity, `RelaySelection.automatic` +keeps the relay recorded in the key file (a fixed region keeps the token +stable across restarts); `.region(id)` and `.hosts([...])` override it. + +### Notes + +- Blocking C calls (server start, ping, path, connect, token resolve) + run on a dedicated dispatch queue, never on an actor or the + cooperative pool. Everything else is quick. +- `Connection` is backed by DispatchIO: `receive` returns as soon as + any bytes are available (up to `maxLength`), `send` completes once the + data has been handed to the tunnel, `closeWrite` is a TCP half-close, + and `close` (also run by deinit) closes the descriptor exactly once. + One receive at a time; `incoming` is a pull-based stream over it. +- `start()` returns once the server is configured, like the tailcat CLI; + the relay connection completes in the background right after, so a + client's first `ping` may throw `TailcatError.timeout` and is worth + retrying. +- Closing a server or client also closes its listeners and connections + on the Go side: their reads see EOF and their accepts throw + `TailcatError.closed`. The Swift objects still own their descriptors + until closed or deinitialized. +- Errors are `TailcatError`; the Go side's message is carried in + `.internalError`, `.invalidToken` and `.invalidKey`. + +## The demo + +`tailcat-demo` is a small command line tool and the interop check +against the Go CLI: + +```sh +swift run tailcat-demo serve 7777 # prints the token on stderr, echoes bytes back uppercased +swift run tailcat-demo connect 7777 # pings, prints latency and path, pipes stdin, prints the reply +swift run tailcat-demo parse +swift run tailcat-demo genkey +``` + +Against the Go CLI, in the repository root: + +```sh +printf 'hi there\n' | go run ./cmd/tailcat 7777 # prints HI THERE + +go run ./cmd/tailcat serve 8080 # with a local server on 8080 +printf 'GET / HTTP/1.0\r\n\r\n' | swift run tailcat-demo connect 8080 +``` + +Set `TAILCAT_VERBOSE=1` to see the Go side's logs. + +## iOS + +The package builds for iOS devices and the simulator: + +```sh +xcodebuild -scheme TailcatKit -destination 'generic/platform=iOS' -derivedDataPath build build CODE_SIGNING_ALLOWED=NO +xcodebuild -scheme TailcatKit -destination 'generic/platform=iOS Simulator' -derivedDataPath build build CODE_SIGNING_ALLOWED=NO +``` + +Keep `start()`, `ping` and friends off the main actor's critical path +(they are async and run off-thread, but they take seconds), and treat +`Identity.json` as a secret. diff --git a/swift/Sources/TailcatDemo/main.swift b/swift/Sources/TailcatDemo/main.swift new file mode 100644 index 000000000..9d5a471f0 --- /dev/null +++ b/swift/Sources/TailcatDemo/main.swift @@ -0,0 +1,158 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// tailcat-demo exercises TailcatKit from the command line and doubles as +// the interop check against the Go tailcat CLI. +// +// tailcat-demo serve start a server, print its token on stderr, +// echo every connection's bytes back uppercased +// tailcat-demo connect ping (print latency and path), connect, send +// stdin, print what comes back until EOF +// tailcat-demo parse print the token's contents +// tailcat-demo genkey print a new identity JSON and its public key +// +// Set TAILCAT_VERBOSE=1 to see the Go side's logs on stderr. + +import Foundation +import TailcatKit + +func note(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) +} + +func fail(_ message: String) -> Never { + note("tailcat-demo: \(message)") + exit(1) +} + +func makeLogger() -> any LogSink { + ProcessInfo.processInfo.environment["TAILCAT_VERBOSE"] == "1" ? DefaultLogger() : BlackholeLogger() +} + +func parsePort(_ text: String) -> UInt16 { + guard let port = UInt16(text) else { fail("invalid port \(text)") } + return port +} + +func parseToken(_ text: String) -> ConnectionToken { + guard let token = ConnectionToken(rawValue: text) else { fail("invalid token \(text)") } + return token +} + +func milliseconds(_ d: Duration) -> String { + let (s, attos) = d.components + return String(format: "%.1fms", Double(s) * 1000 + Double(attos) / 1e15) +} + +/// Uppercases ASCII letters, leaving every other byte alone. +func uppercased(_ data: Data) -> Data { + Data(data.map { $0 >= 0x61 && $0 <= 0x7A ? $0 - 0x20 : $0 }) +} + +func echo(_ connection: Connection) async { + note("# connection from \(connection.remoteAddress ?? "?") on port \(connection.localPort.map(String.init) ?? "?")") + do { + for try await chunk in connection.incoming { + try await connection.send(uppercased(chunk)) + } + connection.closeWrite() + } catch { + note("# connection error: \(error)") + } + connection.close() + note("# connection closed") +} + +func serve(port: UInt16) async throws { + let server = try TailcatServer(configuration: .init(), logger: makeLogger()) + let listener = try await server.listen(on: port) + note("# public key: \(server.publicKey)") + let token = try await server.start() + note("# Server listening on port \(port) with new address: \(token)") + for try await connection in listener.connections { + Task { await echo(connection) } + } +} + +func connect(token: ConnectionToken, port: UInt16) async throws { + let client = try TailcatClient(token: token, logger: makeLogger()) + // A server that just started may still be connecting to its relay, + // in which case the first probe times out; try a few times. + var latency: Duration? + for attempt in 1...4 { + do { + latency = try await client.ping(timeout: .seconds(5)) + break + } catch TailcatError.timeout where attempt < 4 { + note("# ping timed out, retrying") + } + } + guard let latency else { fail("no answer from the server") } + note("# pong in \(milliseconds(latency)) via the relay") + let path = try await client.path() + if path.isDirect { + note("# direct path via \(path.endpoint ?? "?"), \(milliseconds(path.latency))") + } else { + note("# relayed via DERP(\(path.relayRegionCode ?? "?")), \(milliseconds(path.latency))") + } + let connection = try await client.connect(port: port) + note("# connected to port \(port)") + // stdin is read on a detached task: availableData blocks, which is + // fine for a command line tool. + let sender = Task.detached { + do { + while true { + let chunk = FileHandle.standardInput.availableData + if chunk.isEmpty { break } + try await connection.send(chunk) + } + } catch { + note("# send error: \(error)") + } + connection.closeWrite() + } + for try await chunk in connection.incoming { + FileHandle.standardOutput.write(chunk) + } + sender.cancel() + connection.close() + await client.close() +} + +func parse(token: ConnectionToken) throws { + let info = try token.parse() + print("server public key: \(info.serverPublicKey)") + if let region = info.regionID { + print("DERP region ID: \(region)") + } + if !info.relayHosts.isEmpty { + print("relay hosts: \(info.relayHosts.joined(separator: ", "))") + } + print(String(decoding: info.json, as: UTF8.self)) +} + +func genkey() throws { + let identity = try Identity.generate() + print(identity.json) + print("public key: \(identity.publicKey)") +} + +let args = Array(CommandLine.arguments.dropFirst()) +do { + switch args.first { + case "serve" where args.count == 2: + try await serve(port: parsePort(args[1])) + case "connect" where args.count == 3: + try await connect(token: parseToken(args[1]), port: parsePort(args[2])) + case "parse" where args.count == 2: + try parse(token: parseToken(args[1])) + case "genkey" where args.count == 1: + try genkey() + default: + note("usage: tailcat-demo serve | connect | parse | genkey") + exit(2) + } +} catch { + fail("\(error)") +} +exit(0) diff --git a/swift/Sources/TailcatKit/Blocking.swift b/swift/Sources/TailcatKit/Blocking.swift new file mode 100644 index 000000000..e713f2262 --- /dev/null +++ b/swift/Sources/TailcatKit/Blocking.swift @@ -0,0 +1,43 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import Dispatch + +/// Runs blocking C calls off the Swift concurrency thread pool. +/// +/// tailcat_server_start, tailcat_client_ping, tailcat_client_path_json, +/// tailcat_client_dial and tailcat_token_resolve block for the duration +/// of their network work, so they run on a dedicated dispatch queue and +/// callers await a continuation. They are never called on an actor +/// executor or on the cooperative pool. +enum Blocking { + static let queue = DispatchQueue( + label: "dev.tailcat.blocking", + qos: .userInitiated, + attributes: .concurrent + ) + + /// Runs body on the blocking queue and returns its result. + static func run(_ body: @escaping @Sendable () throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + continuation.resume(with: Result(catching: body)) + } + } + } +} + +extension Duration { + /// The duration in whole milliseconds as the C layer takes timeouts, + /// clamped to Int32. Zero means no limit beyond tailcat's own. + var millisecondsForC: Int32 { + let (seconds, attoseconds) = components + if seconds < 0 { + return 0 + } + if seconds >= Int64(Int32.max) / 1000 { + return Int32.max + } + return Int32(clamping: seconds * 1000 + attoseconds / 1_000_000_000_000_000) + } +} diff --git a/swift/Sources/TailcatKit/Connection.swift b/swift/Sources/TailcatKit/Connection.swift new file mode 100644 index 000000000..87531cf7a --- /dev/null +++ b/swift/Sources/TailcatKit/Connection.swift @@ -0,0 +1,281 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import CTailcat +import Dispatch +import Foundation +import os + +/// A TCP connection through the tunnel: one accepted by a Listener or one +/// opened by TailcatClient.connect. +/// +/// The connection is one end of a socketpair pumped by the Go side. It is +/// driven by DispatchIO, so reads and writes never block a Swift +/// concurrency thread. Reads deliver whatever has arrived (up to +/// maxLength) as soon as anything has; the Go side applies TCP +/// backpressure once the socket buffer and one read's worth of internal +/// buffering are full. One receive (or incoming stream consumer) at a +/// time; sends may overlap with receives. +public final class Connection: Sendable { + /// The peer's address as "ip:port"; nil for connections opened by + /// TailcatClient.connect. + public let remoteAddress: String? + /// The server port the peer dialed; nil for connections opened by + /// TailcatClient.connect. + public let localPort: UInt16? + + private let fd: Int32 + private let queue: DispatchQueue + private let io: DispatchIO + private let state: OSAllocatedUnfairLock + + private struct State: Sendable { + var closed = false + var writeClosed = false + /// Bytes read but not yet handed to a receiver. + var buffer = Data() + var eof = false + var readError: TailcatError? + /// Whether a DispatchIO read is outstanding, and its bookkeeping: + /// an operation that ends without error before delivering what it + /// asked for hit EOF. + var reading = false + var readRequested = 0 + var readDelivered = 0 + /// The receive waiting for data, if any. + var receiver: CheckedContinuation? + var receiverMax = 0 + } + + /// Wraps the descriptor, which the connection now owns and closes + /// exactly once. + init(fd: Int32, remoteAddress: String?, localPort: UInt16?) { + self.fd = fd + self.remoteAddress = remoteAddress + self.localPort = localPort + self.state = OSAllocatedUnfairLock(initialState: State()) + let queue = DispatchQueue(label: "dev.tailcat.connection") + self.queue = queue + self.io = DispatchIO(type: .stream, fileDescriptor: fd, queue: queue) { _ in + // Runs once the channel is closed and every operation on it + // has finished: the one moment the descriptor is no longer in + // use, and the only place it is closed. + _ = Darwin.close(fd) + } + // Deliver reads as soon as any byte is available rather than + // waiting for a full chunk. + io.setLimit(lowWater: 1) + } + + deinit { + close() + } + + /// Sends data, returning once it has been handed to the tunnel (the + /// Go side forwards it as the peer accepts it). Throws + /// TailcatError.closed when the connection is closed, or + /// TailcatError.posix for a write error such as EPIPE after the peer + /// went away. Task cancellation does not interrupt a send in + /// progress. + public func send(_ data: Data) async throws { + try state.withLock { s in + if s.closed { + throw TailcatError.closed + } + } + if data.isEmpty { + return + } + let chunk = data.withUnsafeBytes { DispatchData(bytes: $0) } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + io.write(offset: 0, data: chunk, queue: queue) { [weak self] done, _, error in + guard done else { return } + if error == 0 { + continuation.resume() + } else { + continuation.resume(throwing: self?.ioError(error) ?? TailcatError.closed) + } + } + } + } + + /// Receives up to maxLength bytes, returning as soon as any are + /// available. Returns empty Data at EOF (the peer closed or + /// half-closed its side). Throws TailcatError.closed once the + /// connection is closed, and CancellationError if the task is + /// cancelled while waiting; data arriving meanwhile is kept for the + /// next receive. + public func receive(maxLength: Int = 65_536) async throws -> Data { + guard maxLength > 0 else { + throw TailcatError.internalError("receive needs a positive maxLength") + } + try Task.checkCancellation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + enum Action { + case resume(Data) + case fail(TailcatError) + case wait(startRead: Bool) + } + let action: Action = state.withLock { s in + if s.closed { + return .fail(.closed) + } + if !s.buffer.isEmpty { + return .resume(Self.take(&s, maxLength)) + } + if let error = s.readError { + return .fail(error) + } + if s.eof { + return .resume(Data()) + } + if s.receiver != nil { + return .fail(.internalError("a receive is already in progress")) + } + s.receiver = continuation + s.receiverMax = maxLength + if s.reading { + return .wait(startRead: false) + } + s.reading = true + s.readRequested = maxLength + s.readDelivered = 0 + return .wait(startRead: true) + } + switch action { + case .resume(let data): + continuation.resume(returning: data) + case .fail(let error): + continuation.resume(throwing: error) + case .wait(let startRead): + if startRead { + self.startRead(length: maxLength) + } + } + } + } onCancel: { + let receiver = state.withLock { s -> CheckedContinuation? in + let r = s.receiver + s.receiver = nil + return r + } + receiver?.resume(throwing: CancellationError()) + } + } + + /// The bytes the peer sends, as they arrive, until EOF. Pull-based: + /// each step is one receive(), so it applies backpressure and stops + /// when the consuming task is cancelled. Single consumer. + public var incoming: AsyncThrowingStream { + AsyncThrowingStream(unfolding: { [self] in + let chunk = try await self.receive() + return chunk.isEmpty ? nil : chunk + }) + } + + /// Half-closes the connection for writing (shutdown(2) with SHUT_WR): + /// the peer's reads see EOF once it has read everything sent, while + /// its writes still arrive here. Idempotent. + public func closeWrite() { + state.withLock { s in + guard !s.closed, !s.writeClosed else { return } + s.writeClosed = true + // Under the lock so the descriptor cannot be closed meanwhile. + _ = Darwin.shutdown(fd, SHUT_WR) + } + } + + /// Closes the connection: outstanding operations end with + /// TailcatError.closed, the Go side tears the tunnel connection down, + /// and the descriptor is closed once DispatchIO is done with it. + /// Idempotent; deinit calls it. + public func close() { + let (first, receiver) = state.withLock { s -> (Bool, CheckedContinuation?) in + if s.closed { + return (false, nil) + } + s.closed = true + let r = s.receiver + s.receiver = nil + return (true, r) + } + guard first else { return } + io.close(flags: .stop) + receiver?.resume(throwing: TailcatError.closed) + } + + private func startRead(length: Int) { + io.read(offset: 0, length: length, queue: queue) { [weak self] done, data, error in + self?.handleRead(done: done, data: data, error: error) + } + } + + private func handleRead(done: Bool, data: DispatchData?, error: Int32) { + typealias Resume = (CheckedContinuation, Result) + let (resume, restart): (Resume?, Int?) = state.withLock { s in + if let data, !data.isEmpty { + for region in data.regions { + region.withUnsafeBytes { s.buffer.append(contentsOf: $0) } + } + s.readDelivered += data.count + } + if done { + s.reading = false + if error != 0 { + s.readError = error == ECANCELED || s.closed ? .closed : TailcatError.posix(error) + } else if s.readDelivered < s.readRequested { + s.eof = true + } + } + guard let receiver = s.receiver else { + return (nil, nil) + } + if !s.buffer.isEmpty { + s.receiver = nil + return ((receiver, .success(Self.take(&s, s.receiverMax))), nil) + } + if let readError = s.readError { + s.receiver = nil + return ((receiver, .failure(readError)), nil) + } + if s.eof { + s.receiver = nil + return ((receiver, .success(Data())), nil) + } + if !s.reading && !s.closed { + // The operation ended without anything for the waiting + // receiver (it completed its length in an earlier + // delivery); read again on its behalf. + s.reading = true + s.readRequested = s.receiverMax + s.readDelivered = 0 + return (nil, s.receiverMax) + } + return (nil, nil) + } + if let restart { + startRead(length: restart) + } + if let (receiver, result) = resume { + receiver.resume(with: result.mapError { $0 as any Error }) + } + } + + /// Removes and returns up to max bytes from the buffer. + private static func take(_ s: inout State, _ max: Int) -> Data { + let n = Swift.min(max, s.buffer.count) + let out = Data(s.buffer.prefix(n)) + s.buffer.removeFirst(n) + return out + } + + /// Maps a DispatchIO error code. + private func ioError(_ error: Int32) -> TailcatError { + if error == ECANCELED { + return .closed + } + let closed = state.withLock { $0.closed } + return closed ? .closed : TailcatError.posix(error) + } +} diff --git a/swift/Sources/TailcatKit/ConnectionToken.swift b/swift/Sources/TailcatKit/ConnectionToken.swift new file mode 100644 index 000000000..1b517f512 --- /dev/null +++ b/swift/Sources/TailcatKit/ConnectionToken.swift @@ -0,0 +1,187 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import CTailcat +import Foundation + +/// A node's public key in tailcat's text form, "nodekey:" followed by 64 +/// hex digits. Servers allow clients by this key. +public struct NodePublicKey: Sendable, Hashable, Codable, RawRepresentable, CustomStringConvertible { + /// The text form, "nodekey:". + public let rawValue: String + + /// Creates a key from its text form, or returns nil unless it is + /// "nodekey:" followed by exactly 64 hex digits. + public init?(rawValue: String) { + guard rawValue.hasPrefix("nodekey:") else { return nil } + let hex = rawValue.dropFirst("nodekey:".count) + guard hex.count == 64, hex.allSatisfy({ $0.isASCII && $0.isHexDigit }) else { return nil } + self.rawValue = rawValue + } + + /// The text form. + public var description: String { rawValue } + + /// Decodes the text form, validating it. + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + guard let key = NodePublicKey(rawValue: raw) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "not a nodekey: string")) + } + self = key + } + + /// Encodes the text form. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// A server's connection token (the "tc..." string a tailcat server +/// announces), which names the server's keys and its relay. +public struct ConnectionToken: Sendable, Hashable, Codable, RawRepresentable, CustomStringConvertible { + /// The token text. + public let rawValue: String + + /// Creates a token from its text. Only the "tc" prefix is checked + /// here; parse() validates the rest. + public init?(rawValue: String) { + guard rawValue.hasPrefix("tc"), rawValue.count > 2 else { return nil } + self.rawValue = rawValue + } + + /// The token text. + public var description: String { rawValue } + + /// Decodes the token text, checking its prefix. + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + guard let token = ConnectionToken(rawValue: raw) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "not a tc... token")) + } + self = token + } + + /// Encodes the token text. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + /// Decodes the token without touching the network (tailcat_token_parse, + /// the same as "tailcat parse"). Throws TailcatError.invalidToken for a + /// malformed token. + public func parse() throws -> TokenInfo { + var out: UnsafeMutablePointer? = nil + let err = rawValue.withCString { tailcat_token_parse($0, &out) } + if let message = CStrings.take(err) { + free(out) + throw TailcatError.invalidToken(message) + } + guard let json = CStrings.takeData(out) else { + throw TailcatError.internalError("tailcat_token_parse returned no JSON") + } + return try TokenInfo(json: json) + } + + /// Returns the self-contained form of the token, with the relay's + /// details embedded so clients need no DERP map fetch (the same as + /// "tailcat resolve"). A token that already embeds them comes back + /// unchanged. The DERP map is fetched from derpMapURL, or the default + /// map when nil. The work runs off the Swift concurrency threads. A + /// zero timeout means no limit beyond the fetch's own. + public func resolved(derpMapURL: URL? = nil, timeout: Duration = .seconds(10)) async throws -> ConnectionToken { + let token = rawValue + let url = derpMapURL?.absoluteString + let ms = timeout.millisecondsForC + let resolved: String = try await Blocking.run { + var out: UnsafeMutablePointer? = nil + let err = token.withCString { t -> UnsafeMutablePointer? in + if let url { + return url.withCString { tailcat_token_resolve(t, $0, ms, &out) } + } + return tailcat_token_resolve(t, nil, ms, &out) + } + if let message = CStrings.take(err) { + free(out) + switch TailcatError.classify(message: message) { + case .timeout: throw TailcatError.timeout + default: throw TailcatError.invalidToken(message) + } + } + guard let text = CStrings.take(out) else { + throw TailcatError.internalError("tailcat_token_resolve returned no token") + } + return text + } + guard let result = ConnectionToken(rawValue: resolved) else { + throw TailcatError.internalError("tailcat_token_resolve returned an unexpected token") + } + return result + } +} + +/// The contents of a connection token. +public struct TokenInfo: Sendable, Hashable { + /// The server's node public key. + public let serverPublicKey: NodePublicKey + /// The DERP map region the server uses, when the token references one + /// by ID; nil when it embeds the relay details instead. + public let regionID: Int? + /// The hostnames of the relays embedded in the token, in order; empty + /// when the token references a region by ID. + public let relayHosts: [String] + /// The full decoded token as JSON, the output of "tailcat parse". + public let json: Data + + /// Decodes the JSON of tailcat_token_parse. + init(json: Data) throws { + let raw: RawToken + do { + raw = try JSONDecoder().decode(RawToken.self, from: json) + } catch { + throw TailcatError.internalError("decoding token JSON: \(error)") + } + guard let key = NodePublicKey(rawValue: raw.serverPublic) else { + throw TailcatError.invalidToken("unexpected server public key \(raw.serverPublic)") + } + serverPublicKey = key + let hosts = (raw.region ?? []).flatMap { $0.nodes ?? [] }.compactMap { $0.hostName }.filter { !$0.isEmpty } + relayHosts = hosts + if let id = raw.regionID, id > 0, hosts.isEmpty { + regionID = id + } else { + regionID = nil + } + self.json = json + } + + private struct RawToken: Decodable { + var serverPublic: String + var regionID: Int? + var region: [RawRegion]? + + enum CodingKeys: String, CodingKey { + case serverPublic = "ServerPublic" + case regionID = "RegionID" + case region = "Region" + } + } + + private struct RawRegion: Decodable { + var nodes: [RawNode]? + + enum CodingKeys: String, CodingKey { + case nodes = "Nodes" + } + } + + private struct RawNode: Decodable { + var hostName: String? + + enum CodingKeys: String, CodingKey { + case hostName = "HostName" + } + } +} diff --git a/swift/Sources/TailcatKit/Identity.swift b/swift/Sources/TailcatKit/Identity.swift new file mode 100644 index 000000000..7560feb58 --- /dev/null +++ b/swift/Sources/TailcatKit/Identity.swift @@ -0,0 +1,94 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import CTailcat +import Foundation + +/// A tailcat identity: the private key JSON as written by "tailcat genkey" +/// (a tailcat.PrivateKey, with the relay choice recorded in its Public +/// part). It is a private key: store it in the Keychain, not in +/// preferences or logs. Codable as the JSON string itself. +public struct Identity: Sendable, Codable, Hashable { + /// The key file JSON. + public let json: String + + private let cachedPublicKey: NodePublicKey + + /// Adopts an existing key JSON, validating it (tailcat_key_public). + /// Throws TailcatError.invalidKey when it does not parse. + public init(json: String) throws { + var out: UnsafeMutablePointer? = nil + let err = json.withCString { tailcat_key_public($0, &out) } + if let message = CStrings.take(err) { + free(out) + throw TailcatError.invalidKey(message) + } + guard let text = CStrings.take(out), let key = NodePublicKey(rawValue: text) else { + throw TailcatError.invalidKey("unexpected public key format") + } + self.json = json + self.cachedPublicKey = key + } + + /// Generates a new identity (tailcat_key_generate) whose relay is + /// picked automatically when a server starts with it, the same as + /// "tailcat genkey". + public static func generate() throws -> Identity { + var out: UnsafeMutablePointer? = nil + if let message = CStrings.take(tailcat_key_generate(&out)) { + free(out) + throw TailcatError.internalError(message) + } + guard let json = CStrings.take(out) else { + throw TailcatError.internalError("tailcat_key_generate returned no key") + } + return try Identity(json: json) + } + + /// The identity's node public key, "nodekey:". Computed once, at + /// init. + public var publicKey: NodePublicKey { cachedPublicKey } + + /// The token a server using this identity will announce + /// (tailcat_key_token). It is only known ahead of time when the key + /// names a fixed DERP region or embeds relay hosts; with automatic + /// selection it throws TailcatError.relayNotFixed, and the token must + /// be read from a started TailcatServer instead. + public func token() throws -> ConnectionToken { + var out: UnsafeMutablePointer? = nil + let err = json.withCString { tailcat_key_token($0, &out) } + if let message = CStrings.take(err) { + free(out) + if message.lowercased().contains("region") { + throw TailcatError.relayNotFixed + } + throw TailcatError.invalidKey(message) + } + guard let text = CStrings.take(out), let token = ConnectionToken(rawValue: text) else { + throw TailcatError.internalError("tailcat_key_token returned an unexpected token") + } + return token + } + + /// Decodes the key JSON (as a single string), validating it. + public init(from decoder: any Decoder) throws { + let json = try decoder.singleValueContainer().decode(String.self) + try self.init(json: json) + } + + /// Encodes the key JSON as a single string. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(json) + } + + /// Identities are equal when their JSON is. + public static func == (lhs: Identity, rhs: Identity) -> Bool { + lhs.json == rhs.json + } + + /// Hashes the JSON. + public func hash(into hasher: inout Hasher) { + hasher.combine(json) + } +} diff --git a/swift/Sources/TailcatKit/Listener.swift b/swift/Sources/TailcatKit/Listener.swift new file mode 100644 index 000000000..b240169b3 --- /dev/null +++ b/swift/Sources/TailcatKit/Listener.swift @@ -0,0 +1,218 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import CTailcat +import Dispatch +import Foundation +import os + +/// A port registered on a TailcatServer, from which accepted connections +/// are taken. Closing it (or the server) unregisters the port. +public actor Listener { + /// The registered port; 0 is the catch-all listener, which receives + /// connections to every port that has no listener of its own. + public nonisolated let port: UInt16 + + private let core: ListenerCore + private let logger: any LogSink + + /// Wraps a tailcat_listener descriptor, which the listener now owns. + init(port: UInt16, fd: Int32, logger: any LogSink) { + self.port = port + self.core = ListenerCore(fd: fd) + self.logger = logger + } + + deinit { + core.close() + } + + /// Waits for the next connection. It awaits the listener descriptor's + /// readability with a DispatchSource, then dequeues the connection + /// (tailcat_accept and tailcat_conn_info). Throws TailcatError.closed + /// once the listener or its server is closed, and CancellationError if + /// the task is cancelled while waiting. One accept at a time. + public func accept() async throws -> Connection { + try await core.waitReadable() + let connection = try core.accept() + logger.log("Listener: accepted \(connection.remoteAddress ?? "?") on port \(connection.localPort ?? port)") + return connection + } + + /// The connections as they arrive, until the listener is closed. + /// Each step is one accept(), so it stops when the consuming task is + /// cancelled. Single consumer. + public nonisolated var connections: AsyncThrowingStream { + AsyncThrowingStream(unfolding: { [self] in + do { + return try await self.accept() + } catch TailcatError.closed { + return nil + } + }) + } + + /// Stops listening: a waiting accept() throws TailcatError.closed and + /// the descriptor is closed, which unregisters the port on the Go + /// side. Idempotent; deinit calls it. Connections already accepted + /// are unaffected. + public func close() { + core.close() + } +} + +/// The lock-protected state behind a Listener, shared with the dispatch +/// handlers that watch the descriptor. +final class ListenerCore: Sendable { + /// A read source, as a Sendable value for the state. + private struct SourceRef: @unchecked Sendable { + let source: any DispatchSourceRead + } + + private struct State: Sendable { + var fd: Int32 + var closed = false + /// The source armed for the accept in progress, if any. While it + /// is set, its cancel handler owns closing the descriptor. + var source: SourceRef? + /// The accept waiting for readability, if any. + var waiter: CheckedContinuation? + } + + private let state: OSAllocatedUnfairLock + private static let queue = DispatchQueue(label: "dev.tailcat.listener") + + init(fd: Int32) { + state = OSAllocatedUnfairLock(initialState: State(fd: fd)) + } + + /// Suspends until the descriptor is readable, that is, until + /// tailcat_accept will not block. + func waitReadable() async throws { + try Task.checkCancellation() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let armed: Result = state.withLock { s in + if s.closed || s.fd < 0 { + return .failure(.closed) + } + if s.source != nil || s.waiter != nil { + return .failure(.internalError("an accept is already in progress")) + } + let ref = SourceRef(source: DispatchSource.makeReadSource(fileDescriptor: s.fd, queue: Self.queue)) + s.source = ref + s.waiter = continuation + return .success(ref) + } + switch armed { + case .failure(let error): + continuation.resume(throwing: error) + case .success(let ref): + ref.source.setEventHandler { [state, ref] in + let waiter = state.withLock { s -> CheckedContinuation? in + let w = s.waiter + s.waiter = nil + return w + } + ref.source.cancel() + waiter?.resume() + } + ref.source.setCancelHandler { [state, ref] in + // The source is unregistered now, so the + // descriptor may be closed if close() asked for it. + let (fdToClose, waiter) = state.withLock { s -> (Int32, CheckedContinuation?) in + var fdToClose: Int32 = -1 + if let current = s.source, current.source === ref.source { + s.source = nil + if s.closed { + fdToClose = s.fd + s.fd = -1 + } + } + let w = s.waiter + s.waiter = nil + return (fdToClose, w) + } + if fdToClose >= 0 { + _ = Darwin.close(fdToClose) + } + waiter?.resume(throwing: TailcatError.closed) + } + ref.source.activate() + } + } + } onCancel: { + let (ref, waiter) = state.withLock { s -> (SourceRef?, CheckedContinuation?) in + let w = s.waiter + s.waiter = nil + return (s.source, w) + } + ref?.source.cancel() + waiter?.resume(throwing: CancellationError()) + } + } + + /// Dequeues one connection. Call after waitReadable, so the read does + /// not block; the descriptor stays open meanwhile because only close() + /// closes it, and it runs on the same actor. + func accept() throws -> Connection { + let fd = try state.withLock { s -> Int32 in + guard !s.closed, s.fd >= 0 else { + throw TailcatError.closed + } + return s.fd + } + // Dispatch may have put the descriptor in non-blocking mode; + // tailcat_accept expects a blocking read (the data is there). + let flags = fcntl(fd, F_GETFL) + if flags >= 0, flags & O_NONBLOCK != 0 { + _ = fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) + } + var connFd: Int32 = -1 + let rc = tailcat_accept(fd, &connFd) + guard rc == 0, connFd >= 0 else { + // The Go side closed the listener (the server closed) or no + // longer knows it; either way it is finished. + close() + throw TailcatError.closed + } + var buf = [CChar](repeating: 0, count: 128) + var localPort: Int32 = 0 + let irc = buf.withUnsafeMutableBufferPointer { p in + tailcat_conn_info(fd, connFd, p.baseAddress, p.count, &localPort) + } + let known = irc == 0 && localPort >= 0 && localPort <= 65535 + return Connection( + fd: connFd, + remoteAddress: known ? CStrings.string(buf) : nil, + localPort: known ? UInt16(localPort) : nil + ) + } + + /// Closes the listener once: wakes a waiting accept with + /// TailcatError.closed and closes the descriptor, directly or from the + /// armed source's cancel handler. + func close() { + let (fdToClose, ref, waiter) = state.withLock { s -> (Int32, SourceRef?, CheckedContinuation?) in + if s.closed { + return (-1, nil, nil) + } + s.closed = true + let w = s.waiter + s.waiter = nil + if let ref = s.source { + return (-1, ref, w) + } + let fd = s.fd + s.fd = -1 + return (fd, nil, w) + } + if let ref { + ref.source.cancel() + } + if fdToClose >= 0 { + _ = Darwin.close(fdToClose) + } + waiter?.resume(throwing: TailcatError.closed) + } +} diff --git a/swift/Sources/TailcatKit/LogSink.swift b/swift/Sources/TailcatKit/LogSink.swift new file mode 100644 index 000000000..ae8dc79ca --- /dev/null +++ b/swift/Sources/TailcatKit/LogSink.swift @@ -0,0 +1,45 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import Foundation +import os + +/// Where TailcatKit and the Go side send their logs. +public protocol LogSink: Sendable { + /// A descriptor the Go side writes its log lines to, or nil to discard + /// them. It stays owned by the sink and must remain open for the life + /// of the servers and clients using it. + var logFileDescriptor: Int32? { get } + + /// Receives TailcatKit's own messages. + func log(_ message: String) +} + +/// Sends the Go side's logs to stderr and TailcatKit's to the unified +/// logging system (os.Logger, subsystem dev.tailcat.TailcatKit). +public struct DefaultLogger: LogSink { + private static let logger = Logger(subsystem: "dev.tailcat.TailcatKit", category: "TailcatKit") + + /// Creates the logger. + public init() {} + + /// Standard error, for the Go side's lines. + public var logFileDescriptor: Int32? { STDERR_FILENO } + + /// Logs message at the info level. + public func log(_ message: String) { + Self.logger.info("\(message, privacy: .public)") + } +} + +/// Discards everything, on both sides (the Go side gets -1). +public struct BlackholeLogger: LogSink { + /// Creates the logger. + public init() {} + + /// Nil: the Go side discards its logs. + public var logFileDescriptor: Int32? { nil } + + /// Discards message. + public func log(_ message: String) {} +} diff --git a/swift/Sources/TailcatKit/Relay.swift b/swift/Sources/TailcatKit/Relay.swift new file mode 100644 index 000000000..d86b72023 --- /dev/null +++ b/swift/Sources/TailcatKit/Relay.swift @@ -0,0 +1,59 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import Foundation + +/// Which DERP relay a server bootstraps through (and clients rendezvous +/// at). +public enum RelaySelection: Sendable, Hashable { + /// The relay recorded in the server's identity if it has one, + /// otherwise the nearest region of the DERP map, measured at start. + case automatic + /// A DERP map region by ID (tailcat_server_set_region_id). It + /// overrides the identity's choice; 0 means the nearest region, + /// ignoring the identity. + case region(Int) + /// Your own DERP relays, by hostname (tailcat_server_set_relay_hosts). + /// The server connects to the first; the token always embeds them. + case hosts([String]) +} + +/// What a TailcatServer is built from. +public struct ServerConfiguration: Sendable { + /// The server's identity; nil generates an ephemeral one whose token + /// nobody has seen before. + public var identity: Identity? = nil + /// The relay to use. + public var relay: RelaySelection = .automatic + /// The DERP map to resolve regions against; nil uses tailcat's default + /// map (https://tailcat.dev/derpmap.json). + public var derpMapURL: URL? = nil + /// Whether the token embeds the relay's details (self-contained but + /// longer, like "tailcat serve --full-address") instead of a region + /// ID. Relay hosts are always embedded. + public var embedRelayInToken: Bool = false + /// Clients allowed to connect, by public key. Empty allows everyone; + /// once any key is listed (or allowed later), only listed keys can + /// connect. + public var allowedClients: [NodePublicKey] = [] + + /// The defaults: an ephemeral identity, automatic relay selection, the + /// default DERP map, a short token, every client allowed. + public init() {} + + /// A configuration with the given settings; unspecified ones keep + /// their defaults. + public init( + identity: Identity? = nil, + relay: RelaySelection = .automatic, + derpMapURL: URL? = nil, + embedRelayInToken: Bool = false, + allowedClients: [NodePublicKey] = [] + ) { + self.identity = identity + self.relay = relay + self.derpMapURL = derpMapURL + self.embedRelayInToken = embedRelayInToken + self.allowedClients = allowedClients + } +} diff --git a/swift/Sources/TailcatKit/TailcatClient.swift b/swift/Sources/TailcatKit/TailcatClient.swift new file mode 100644 index 000000000..72302a04d --- /dev/null +++ b/swift/Sources/TailcatKit/TailcatClient.swift @@ -0,0 +1,198 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import CTailcat +import Foundation + +/// A tailcat client: given a server's connection token, it pings the +/// server and dials TCP ports on it. +/// +/// Nothing happens on the network until the first ping, path or connect, +/// which brings the tunnel up (resolving the relay, connecting to it and +/// registering with the server). Those calls run off the Swift +/// concurrency threads. +public actor TailcatClient { + /// The server's connection token. + public nonisolated let token: ConnectionToken + + private var handle: Int32 + private let logger: any LogSink + + /// Creates a client for the server named by token (tailcat_client_new), + /// with an optional identity (so the server can allow it by public + /// key; ephemeral otherwise) and DERP map URL (used when the token + /// references a region by ID). Throws TailcatError.invalidToken for a + /// malformed token. + public init(token: ConnectionToken, identity: Identity? = nil, derpMapURL: URL? = nil, logger: any LogSink = BlackholeLogger()) throws { + let h = token.rawValue.withCString { tailcat_client_new($0) } + guard h != 0 else { + var message = "malformed token" + do { + _ = try token.parse() + } catch TailcatError.invalidToken(let text) { + message = text + } catch {} + throw TailcatError.invalidToken(message) + } + do { + try TailcatError.check(tailcat_set_logfd(h, logger.logFileDescriptor ?? -1), handle: h) + if let identity { + try TailcatError.check(identity.json.withCString { tailcat_client_set_key(h, $0) }, handle: h) + } + if let derpMapURL { + try TailcatError.check(derpMapURL.absoluteString.withCString { tailcat_client_set_derpmap_url(h, $0) }, handle: h) + } + } catch { + _ = tailcat_client_close(h) + throw error + } + self.token = token + self.handle = h + self.logger = logger + } + + deinit { + if handle != 0 { + _ = tailcat_client_close(handle) + } + } + + /// The client's node public key, "nodekey:", generating the + /// ephemeral key if no identity was given. Give it to the server's + /// allow(_:). + public var publicKey: NodePublicKey { + get throws { + let h = try activeHandle() + var buf = [CChar](repeating: 0, count: 128) + try TailcatError.check(buf.withUnsafeMutableBufferPointer { tailcat_client_public_key(h, $0.baseAddress, $0.count) }, handle: h) + guard let key = NodePublicKey(rawValue: CStrings.string(buf)) else { + throw TailcatError.internalError("unexpected client public key format") + } + return key + } + } + + /// Checks that the server is reachable and accepts this client, and + /// returns the relay round trip (tailcat_client_ping, off-thread). + /// Each call sends one probe: a server that does not allow this + /// client, or one still connecting to its relay right after starting, + /// shows up as TailcatError.timeout, which is worth a retry. A zero + /// timeout means no limit beyond tailcat's own. + public func ping(timeout: Duration = .seconds(10)) async throws -> Duration { + let h = try activeHandle() + let ms = timeout.millisecondsForC + let latencyMs: Double = try await Blocking.run { + var latency = 0.0 + try TailcatError.check(tailcat_client_ping(h, ms, &latency), handle: h) + return latency + } + return .milliseconds(latencyMs) + } + + /// Reports how packets reach the server (tailcat_client_path_json, + /// off-thread): a direct path, or the relay carrying them. Calling it + /// repeatedly nudges direct path discovery along. A zero timeout means + /// no limit beyond tailcat's own. + public func path(timeout: Duration = .seconds(10)) async throws -> PathInfo { + let h = try activeHandle() + let ms = timeout.millisecondsForC + let json: Data = try await Blocking.run { + var out: UnsafeMutablePointer? = nil + try TailcatError.check(tailcat_client_path_json(h, ms, &out), handle: h) + guard let json = CStrings.takeData(out) else { + throw TailcatError.internalError("tailcat_client_path_json returned no JSON") + } + return json + } + return try PathInfo(json: json) + } + + /// Opens a TCP connection to port on the server (tailcat_client_dial, + /// off-thread). Throws TailcatError.invalidPort for port 0 and + /// TailcatError.posix(ECONNREFUSED, _) when nothing listens on the + /// port. A zero timeout means no limit beyond tailcat's own. + public func connect(port: UInt16, timeout: Duration = .seconds(15)) async throws -> Connection { + guard port != 0 else { + throw TailcatError.invalidPort + } + let h = try activeHandle() + let ms = timeout.millisecondsForC + let fd: Int32 = try await Blocking.run { + var fd: Int32 = -1 + try TailcatError.check(tailcat_client_dial(h, Int32(port), ms, &fd), handle: h) + guard fd >= 0 else { + throw TailcatError.internalError("tailcat_client_dial returned no connection") + } + return fd + } + logger.log("TailcatClient: connected to port \(port)") + return Connection(fd: fd, remoteAddress: nil, localPort: nil) + } + + /// Shuts the client down: connections opened through it are closed + /// on the Go side (their reads see EOF; the Connection objects still + /// own their descriptors until closed), the tunnel is torn down and + /// the handle is freed. Idempotent; deinit calls it. + public func close() { + guard handle != 0 else { return } + let h = handle + handle = 0 + _ = tailcat_client_close(h) + logger.log("TailcatClient: closed") + } + + private func activeHandle() throws -> Int32 { + guard handle != 0 else { + throw TailcatError.closed + } + return handle + } +} + +/// How a client's packets reach the server, from TailcatClient.path. +public struct PathInfo: Sendable, Hashable { + /// Whether the probe came back over a direct (peer-to-peer) path. + public let isDirect: Bool + /// The direct path's "ip:port", when isDirect. + public let endpoint: String? + /// The code of the DERP region relaying the packets, when not direct. + public let relayRegionCode: String? + /// The probe's round trip. + public let latency: Duration + /// The full result, the JSON encoding of ipnstate.PingResult. + public let json: Data + + /// Decodes the JSON of tailcat_client_path_json. + init(json: Data) throws { + let raw: RawResult + do { + raw = try JSONDecoder().decode(RawResult.self, from: json) + } catch { + throw TailcatError.internalError("decoding path JSON: \(error)") + } + if let err = raw.err, !err.isEmpty { + throw TailcatError.internalError(err) + } + let endpoint = (raw.endpoint ?? "").isEmpty ? nil : raw.endpoint + self.isDirect = endpoint != nil + self.endpoint = endpoint + let code = (raw.derpRegionCode ?? "").isEmpty ? nil : raw.derpRegionCode + self.relayRegionCode = endpoint == nil ? code : nil + self.latency = .seconds(raw.latencySeconds ?? 0) + self.json = json + } + + private struct RawResult: Decodable { + var err: String? + var latencySeconds: Double? + var endpoint: String? + var derpRegionCode: String? + + enum CodingKeys: String, CodingKey { + case err = "Err" + case latencySeconds = "LatencySeconds" + case endpoint = "Endpoint" + case derpRegionCode = "DERPRegionCode" + } + } +} diff --git a/swift/Sources/TailcatKit/TailcatError.swift b/swift/Sources/TailcatKit/TailcatError.swift new file mode 100644 index 000000000..5b715c7c9 --- /dev/null +++ b/swift/Sources/TailcatKit/TailcatError.swift @@ -0,0 +1,139 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import CTailcat +import Foundation + +/// The errors TailcatKit throws. +public enum TailcatError: Error, Sendable, Equatable, CustomStringConvertible { + /// The underlying C handle is not valid, typically because the object + /// was closed. + case invalidHandle + /// The connection token is malformed; the payload is the parser's + /// message. + case invalidToken(String) + /// The identity JSON is malformed; the payload is the parser's message. + case invalidKey(String) + /// The identity leaves the relay to be picked when a server starts, so + /// its token is only known once such a server is running. + case relayNotFixed + /// The operation needs a started server. + case notStarted + /// The server is already starting or started. + case alreadyStarted + /// The object (server, client, listener or connection) is closed. + case closed + /// The operation did not complete within its timeout. + case timeout + /// The port is not valid for the operation. + case invalidPort + /// A POSIX error: errno and its text. + case posix(Int32, String) + /// Any other error, with the text reported by the Go side. + case internalError(String) + + /// A short description of the error. + public var description: String { + switch self { + case .invalidHandle: "invalid handle" + case .invalidToken(let message): "invalid connection token: \(message)" + case .invalidKey(let message): "invalid identity: \(message)" + case .relayNotFixed: "the identity's relay is chosen at start; the token is only known once a server using it has started" + case .notStarted: "the server is not started" + case .alreadyStarted: "the server is already started" + case .closed: "closed" + case .timeout: "timed out" + case .invalidPort: "invalid port" + case .posix(let code, let message): "\(message) (errno \(code))" + case .internalError(let message): message + } + } +} + +extension TailcatError { + /// Throws the error a handle function's return code stands for, if any. + static func check(_ rc: Int32, handle: Int32) throws { + if rc != 0 { + throw fromReturnCode(rc, handle: handle) + } + } + + /// Maps a handle function's non-zero return code: EBADF, ERANGE, or -1 + /// with the message tailcat_errmsg holds for the handle. + static func fromReturnCode(_ rc: Int32, handle: Int32) -> TailcatError { + switch rc { + case EBADF: + return .invalidHandle + case ERANGE: + return .internalError("buffer too small") + default: + return classify(message: lastMessage(handle: handle)) + } + } + + /// Reads the last error message recorded on handle. + static func lastMessage(handle: Int32) -> String { + for size in [1024, 16384] { + var buf = [CChar](repeating: 0, count: size) + let rc = buf.withUnsafeMutableBufferPointer { tailcat_errmsg(handle, $0.baseAddress, $0.count) } + switch rc { + case 0: + let message = CStrings.string(buf) + return message.isEmpty ? "unknown error" : message + case ERANGE: + continue + default: + return "unknown error (handle \(handle) is invalid)" + } + } + return "unknown error (message too long)" + } + + /// Picks the error case that best fits a message from the Go side. + static func classify(message: String) -> TailcatError { + let m = message.lowercased() + if m.contains("deadline exceeded") || m.contains("timeout") || m.contains("timed out") { + return .timeout + } + if m.contains("already started") { + return .alreadyStarted + } + if m.contains("server closed") || m.contains("client closed") { + return .closed + } + if m.contains("connection refused") || m.contains("connection was refused") { + return .posix(ECONNREFUSED, message) + } + return .internalError(message) + } + + /// A POSIX error with the system's text for code. + static func posix(_ code: Int32) -> TailcatError { + .posix(code, String(cString: strerror(code))) + } +} + +/// Helpers for strings crossing the C boundary. +enum CStrings { + /// Returns the text of a malloc'd C string and frees it; nil for NULL. + static func take(_ p: UnsafeMutablePointer?) -> String? { + guard let p else { return nil } + defer { free(p) } + return String(cString: p) + } + + /// Returns the bytes of a malloc'd C string and frees it; nil for NULL. + static func takeData(_ p: UnsafeMutablePointer?) -> Data? { + guard let p else { return nil } + defer { free(p) } + return Data(bytes: p, count: strlen(p)) + } + + /// The string in a NUL-terminated buffer. + static func string(_ buf: [CChar]) -> String { + buf.withUnsafeBufferPointer { p in + guard let base = p.baseAddress, p.contains(0) else { return "" } + return String(cString: base) + } + } +} diff --git a/swift/Sources/TailcatKit/TailcatServer.swift b/swift/Sources/TailcatKit/TailcatServer.swift new file mode 100644 index 000000000..a33f95b86 --- /dev/null +++ b/swift/Sources/TailcatKit/TailcatServer.swift @@ -0,0 +1,197 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import CTailcat +import Foundation + +/// A tailcat server: it announces a connection token, and clients holding +/// the token dial TCP ports on it, which Listeners accept. +/// +/// Create it, register listeners, then start() (which does the network +/// work off the Swift concurrency threads) and share the token. Ports may +/// also be registered after start. +public actor TailcatServer { + /// The server's node public key, "nodekey:", known before start. + public nonisolated let publicKey: NodePublicKey + + /// The connection token, once start() has returned it. + public private(set) var token: ConnectionToken? + + private var handle: Int32 + private var state: State = .idle + private let logger: any LogSink + + private enum State { + case idle, starting, running, closed + } + + /// Creates a server (tailcat_server_new and the tailcat_server_set_* + /// calls). Nothing touches the network until start(). + public init(configuration: ServerConfiguration = .init(), logger: any LogSink = BlackholeLogger()) throws { + let h = tailcat_server_new() + guard h != 0 else { + throw TailcatError.internalError("tailcat_server_new returned no handle") + } + let key: NodePublicKey + do { + try TailcatError.check(tailcat_set_logfd(h, logger.logFileDescriptor ?? -1), handle: h) + if let identity = configuration.identity { + try TailcatError.check(identity.json.withCString { tailcat_server_set_key(h, $0) }, handle: h) + } + switch configuration.relay { + case .automatic: + break + case .region(let id): + guard id >= 0, id <= Int(Int32.max) else { + throw TailcatError.internalError("invalid DERP region ID \(id)") + } + try TailcatError.check(tailcat_server_set_region_id(h, Int32(id)), handle: h) + case .hosts(let hosts): + let list = hosts.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + guard !list.isEmpty else { + throw TailcatError.internalError("the relay host list is empty") + } + try TailcatError.check(list.joined(separator: ",").withCString { tailcat_server_set_relay_hosts(h, $0) }, handle: h) + } + if let url = configuration.derpMapURL { + try TailcatError.check(url.absoluteString.withCString { tailcat_server_set_derpmap_url(h, $0) }, handle: h) + } + if configuration.embedRelayInToken { + try TailcatError.check(tailcat_server_set_embed_relay(h, 1), handle: h) + } + for client in configuration.allowedClients { + try TailcatError.check(client.rawValue.withCString { tailcat_server_allow_client(h, $0) }, handle: h) + } + var buf = [CChar](repeating: 0, count: 128) + try TailcatError.check(buf.withUnsafeMutableBufferPointer { tailcat_server_public_key(h, $0.baseAddress, $0.count) }, handle: h) + guard let parsed = NodePublicKey(rawValue: CStrings.string(buf)) else { + throw TailcatError.internalError("unexpected server public key format") + } + key = parsed + } catch { + _ = tailcat_server_close(h) + throw error + } + self.publicKey = key + self.handle = h + self.logger = logger + logger.log("TailcatServer: created, public key \(key)") + } + + deinit { + if handle != 0 { + _ = tailcat_server_close(handle) + } + } + + /// Registers port (1 to 65535) for incoming connections and returns + /// its Listener; 0 registers the catch-all listener, which receives + /// connections to every port without one. Works before and after + /// start. A port may be registered once until its listener is closed. + public func listen(on port: UInt16) throws -> Listener { + let h = try activeHandle() + var fd: Int32 = -1 + try TailcatError.check(tailcat_server_listen(h, Int32(port), &fd), handle: h) + guard fd >= 0 else { + throw TailcatError.internalError("tailcat_server_listen returned no listener") + } + logger.log("TailcatServer: listening on port \(port)") + return Listener(port: port, fd: fd, logger: logger) + } + + /// Starts the server (tailcat_server_start, off-thread): resolves the + /// relay, fetching the DERP map and measuring latencies as needed, and + /// returns the connection token, also kept in `token`. Throws + /// TailcatError.alreadyStarted on a second call. Like the tailcat CLI + /// it returns once the server is configured; the relay connection + /// completes in the background right after, so a client pinging + /// within the first seconds may time out and should retry. + public func start() async throws -> ConnectionToken { + switch state { + case .closed: + throw TailcatError.closed + case .starting, .running: + throw TailcatError.alreadyStarted + case .idle: + break + } + let h = handle + state = .starting + logger.log("TailcatServer: starting") + do { + try await Blocking.run { + try TailcatError.check(tailcat_server_start(h), handle: h) + } + } catch { + if state == .closed { + throw TailcatError.closed + } + // A failed start may be retried. + state = .idle + throw error + } + guard state == .starting else { + throw TailcatError.closed + } + var buf = [CChar](repeating: 0, count: 4096) + try TailcatError.check(buf.withUnsafeMutableBufferPointer { tailcat_server_token(h, $0.baseAddress, $0.count) }, handle: h) + guard let token = ConnectionToken(rawValue: CStrings.string(buf)) else { + throw TailcatError.internalError("unexpected token format") + } + self.token = token + state = .running + logger.log("TailcatServer: started, token \(token)") + return token + } + + /// Allows a client by public key, before or after start. Until any + /// key is allowed every client may connect; once one is, only allowed + /// keys can. + public func allow(_ key: NodePublicKey) throws { + let h = try activeHandle() + try TailcatError.check(key.rawValue.withCString { tailcat_server_allow_client(h, $0) }, handle: h) + } + + /// The server's WireGuard and relay status, the JSON encoding of + /// ipnstate.Status. Throws TailcatError.notStarted before start. + public func status() async throws -> Data { + switch state { + case .closed: + throw TailcatError.closed + case .idle, .starting: + throw TailcatError.notStarted + case .running: + break + } + let h = handle + return try await Blocking.run { + var out: UnsafeMutablePointer? = nil + try TailcatError.check(tailcat_server_status_json(h, &out), handle: h) + guard let json = CStrings.takeData(out) else { + throw TailcatError.internalError("tailcat_server_status_json returned no JSON") + } + return json + } + } + + /// Shuts the server down: listeners and accepted connections are + /// closed on the Go side (their reads see EOF, their Listener and + /// Connection objects still own their descriptors until closed), the + /// relay connection is torn down and the handle is freed. Idempotent; + /// deinit calls it. + public func close() { + guard handle != 0 else { return } + let h = handle + handle = 0 + state = .closed + _ = tailcat_server_close(h) + logger.log("TailcatServer: closed") + } + + private func activeHandle() throws -> Int32 { + guard handle != 0, state != .closed else { + throw TailcatError.closed + } + return handle + } +} diff --git a/swift/Tests/TailcatKitTests/EndToEndTests.swift b/swift/Tests/TailcatKitTests/EndToEndTests.swift new file mode 100644 index 000000000..331774dfa --- /dev/null +++ b/swift/Tests/TailcatKitTests/EndToEndTests.swift @@ -0,0 +1,106 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import Foundation +import TailcatKit +import XCTest + +/// A server and a client in one process, rendezvousing over the public +/// relays. Runs only with TAILCAT_E2E=1 in the environment. +final class EndToEndTests: XCTestCase { + func testServerClientRoundTrip() async throws { + guard ProcessInfo.processInfo.environment["TAILCAT_E2E"] == "1" else { + throw XCTSkip("set TAILCAT_E2E=1 to run the end-to-end test over the public relays") + } + let started = ContinuousClock.now + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await Self.roundTrip() + } + group.addTask { + try await Task.sleep(for: .seconds(60)) + throw TailcatError.timeout + } + try await group.next() + group.cancelAll() + } + XCTAssertLessThan(ContinuousClock.now - started, .seconds(60)) + } + + static func roundTrip() async throws { + let server = try TailcatServer(configuration: .init(relay: .automatic), logger: BlackholeLogger()) + let listener = try await server.listen(on: 7000) + let token = try await server.start() + XCTAssertTrue(token.rawValue.hasPrefix("tc")) + let kept = await server.token + XCTAssertEqual(kept, token) + let info = try token.parse() + XCTAssertEqual(info.serverPublicKey, server.publicKey) + XCTAssertNotNil(info.regionID) + + // The server side: accept one connection, echo its bytes + // uppercased, then expect the client's half-close. + let serverSide = Task { () -> Bool in + let connection = try await listener.accept() + XCTAssertEqual(connection.localPort, 7000) + XCTAssertNotNil(connection.remoteAddress) + var request = Data() + while request.count < 5 { + let chunk = try await connection.receive() + if chunk.isEmpty { break } + request.append(chunk) + } + XCTAssertEqual(String(decoding: request, as: UTF8.self), "hello") + try await connection.send(Data(request.map { $0 >= 0x61 && $0 <= 0x7A ? $0 - 0x20 : $0 })) + let eof = try await connection.receive() + connection.close() + return eof.isEmpty + } + + let client = try TailcatClient(token: token, logger: BlackholeLogger()) + // The relay connection completes shortly after start() returns, + // so the first probe may time out. + var latency: Duration? + for attempt in 1...6 { + do { + latency = try await client.ping(timeout: .seconds(5)) + break + } catch TailcatError.timeout where attempt < 6 { + continue + } + } + let rtt = try XCTUnwrap(latency) + XCTAssertGreaterThan(rtt, .zero) + + let path = try await client.path() + XCTAssertGreaterThan(path.latency, .zero) + XCTAssertTrue(path.isDirect || path.relayRegionCode != nil, String(decoding: path.json, as: UTF8.self)) + XCTAssertEqual(path.isDirect, path.endpoint != nil) + + let connection = try await client.connect(port: 7000) + XCTAssertNil(connection.remoteAddress) + try await connection.send(Data("hello".utf8)) + var reply = Data() + while reply.count < 5 { + let chunk = try await connection.receive() + if chunk.isEmpty { break } + reply.append(chunk) + } + XCTAssertEqual(String(decoding: reply, as: UTF8.self), "HELLO") + + connection.closeWrite() + let serverSawEOF = try await serverSide.value + XCTAssertTrue(serverSawEOF) + // The server closed its side, so the client reads EOF. + let tail = try await connection.receive() + XCTAssertTrue(tail.isEmpty) + + let status = try await server.status() + XCTAssertNotNil(try JSONSerialization.jsonObject(with: status) as? [String: Any]) + + connection.close() + await client.close() + await listener.close() + await server.close() + } +} diff --git a/swift/Tests/TailcatKitTests/IdentityTests.swift b/swift/Tests/TailcatKitTests/IdentityTests.swift new file mode 100644 index 000000000..9bf44dd66 --- /dev/null +++ b/swift/Tests/TailcatKitTests/IdentityTests.swift @@ -0,0 +1,66 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import Foundation +import TailcatKit +import XCTest + +/// Offline tests of Identity. +final class IdentityTests: XCTestCase { + func testGenerate() throws { + let identity = try Identity.generate() + XCTAssertTrue(identity.publicKey.rawValue.hasPrefix("nodekey:")) + XCTAssertEqual(identity.publicKey.rawValue.count, "nodekey:".count + 64) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(identity.json.utf8)) as? [String: Any]) + XCTAssertNotNil(json["Private"]) + let pub = try XCTUnwrap(json["Public"] as? [String: Any]) + XCTAssertEqual(pub["RegionID"] as? Int, -1) + // Two identities never share a key. + XCTAssertNotEqual(try Identity.generate().publicKey, identity.publicKey) + } + + func testTokenNeedsFixedRelay() throws { + let identity = try Identity.generate() + XCTAssertThrowsError(try identity.token()) { error in + XCTAssertEqual(error as? TailcatError, .relayNotFixed) + } + } + + func testInvalidJSONThrows() { + XCTAssertThrowsError(try Identity(json: "{}")) { error in + guard case TailcatError.invalidKey = error else { + return XCTFail("unexpected error \(error)") + } + } + XCTAssertThrowsError(try Identity(json: "not json")) + } + + func testRoundTrip() throws { + let identity = try Identity.generate() + let again = try Identity(json: identity.json) + XCTAssertEqual(again, identity) + XCTAssertEqual(again.publicKey, identity.publicKey) + let encoded = try JSONEncoder().encode(identity) + XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "\"" + identity.json.replacingOccurrences(of: "\n", with: "\\n").replacingOccurrences(of: "\t", with: "\\t").replacingOccurrences(of: "\"", with: "\\\"") + "\"") + XCTAssertEqual(try JSONDecoder().decode(Identity.self, from: encoded), identity) + XCTAssertThrowsError(try JSONDecoder().decode(Identity.self, from: Data("\"{}\"".utf8))) + } + + func testFixedRegionToken() throws { + // Pin the generated key to region 302 and check the token it + // yields names that region and this key. + let identity = try Identity.generate() + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(identity.json.utf8)) as? [String: Any]) + var pub = try XCTUnwrap(json["Public"] as? [String: Any]) + pub["RegionID"] = 302 + json["Public"] = pub + let pinned = try Identity(json: String(decoding: try JSONSerialization.data(withJSONObject: json), as: UTF8.self)) + XCTAssertEqual(pinned.publicKey, identity.publicKey) + let token = try pinned.token() + XCTAssertTrue(token.rawValue.hasPrefix("tc")) + let info = try token.parse() + XCTAssertEqual(info.serverPublicKey, identity.publicKey) + XCTAssertEqual(info.regionID, 302) + XCTAssertEqual(info.relayHosts, []) + } +} diff --git a/swift/Tests/TailcatKitTests/OfflineTests.swift b/swift/Tests/TailcatKitTests/OfflineTests.swift new file mode 100644 index 000000000..db143885d --- /dev/null +++ b/swift/Tests/TailcatKitTests/OfflineTests.swift @@ -0,0 +1,330 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import Foundation +@testable import TailcatKit +import XCTest + +/// Writes text to the raw peer descriptor of a test socketpair. +func peerWrite(_ fd: Int32, _ text: String) { + let data = Data(text.utf8) + let n = data.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + XCTAssertEqual(n, data.count) +} + +/// Reads up to max bytes from the raw peer descriptor; nil on error. +func peerRead(_ fd: Int32, max: Int = 4096) -> Data? { + var buf = [UInt8](repeating: 0, count: max) + let n = Darwin.read(fd, &buf, max) + return n < 0 ? nil : Data(buf[0.. (Connection, Int32) { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + var one: Int32 = 1 + setsockopt(fds[1], SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + return (Connection(fd: fds[0], remoteAddress: "peer", localPort: 1), fds[1]) + } + + func testReceiveReturnsWhatIsAvailable() async throws { + let (conn, peer) = try makePair() + defer { Darwin.close(peer) } + XCTAssertEqual(conn.remoteAddress, "peer") + XCTAssertEqual(conn.localPort, 1) + peerWrite(peer, "hello") + // Does not wait for maxLength bytes. + let got = try await conn.receive(maxLength: 65_536) + XCTAssertEqual(String(decoding: got, as: UTF8.self), "hello") + conn.close() + } + + func testReceiveHonorsMaxLength() async throws { + let (conn, peer) = try makePair() + defer { Darwin.close(peer) } + peerWrite(peer, "abcdef") + let first = try await conn.receive(maxLength: 2) + XCTAssertEqual(String(decoding: first, as: UTF8.self), "ab") + let rest = try await conn.receive(maxLength: 10) + XCTAssertEqual(String(decoding: rest, as: UTF8.self), "cdef") + conn.close() + } + + func testEOFReturnsEmpty() async throws { + let (conn, peer) = try makePair() + peerWrite(peer, "bye") + Darwin.shutdown(peer, SHUT_WR) + let got = try await conn.receive() + XCTAssertEqual(String(decoding: got, as: UTF8.self), "bye") + let eof = try await conn.receive() + XCTAssertTrue(eof.isEmpty) + let again = try await conn.receive() + XCTAssertTrue(again.isEmpty) + // Writes still flow after the peer's half-close. + try await conn.send(Data("still here".utf8)) + XCTAssertEqual(peerRead(peer), Data("still here".utf8)) + conn.close() + Darwin.close(peer) + } + + func testSendAndCloseWrite() async throws { + let (conn, peer) = try makePair() + try await conn.send(Data("ping".utf8)) + XCTAssertEqual(peerRead(peer), Data("ping".utf8)) + try await conn.send(Data()) + conn.closeWrite() + conn.closeWrite() + XCTAssertEqual(peerRead(peer), Data()) + // Reads still work after our half-close. + peerWrite(peer, "pong") + let got = try await conn.receive() + XCTAssertEqual(String(decoding: got, as: UTF8.self), "pong") + // And sending fails now that writing is shut down. + do { + try await conn.send(Data("late".utf8)) + XCTFail("send after closeWrite succeeded") + } catch TailcatError.posix(let code, _) { + XCTAssertEqual(code, EPIPE) + } + conn.close() + Darwin.close(peer) + } + + func testIncomingStream() async throws { + let (conn, peer) = try makePair() + let writer = Thread { + for i in 0..<20 { + peerWrite(peer, "chunk \(i)\n") + usleep(2_000) + } + Darwin.shutdown(peer, SHUT_WR) + } + writer.start() + var all = Data() + for try await chunk in conn.incoming { + all.append(chunk) + } + let want = (0..<20).map { "chunk \($0)\n" }.joined() + XCTAssertEqual(String(decoding: all, as: UTF8.self), want) + conn.close() + Darwin.close(peer) + } + + func testCloseWakesReceiver() async throws { + let (conn, peer) = try makePair() + defer { Darwin.close(peer) } + let waiting = Task { try await conn.receive() } + try await Task.sleep(for: .milliseconds(50)) + conn.close() + do { + _ = try await waiting.value + XCTFail("receive returned after close") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + // Closing is idempotent and later calls fail cleanly. + conn.close() + do { + _ = try await conn.receive() + XCTFail("receive after close succeeded") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + do { + try await conn.send(Data("x".utf8)) + XCTFail("send after close succeeded") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + // The peer sees EOF once the descriptor is closed. + XCTAssertEqual(peerRead(peer), Data()) + } + + func testCancellationLeavesDataForNextReceive() async throws { + let (conn, peer) = try makePair() + let waiting = Task { try await conn.receive() } + try await Task.sleep(for: .milliseconds(50)) + waiting.cancel() + do { + _ = try await waiting.value + XCTFail("receive returned after cancellation") + } catch { + XCTAssertTrue(error is CancellationError, "unexpected error \(error)") + } + peerWrite(peer, "later") + let got = try await conn.receive() + XCTAssertEqual(String(decoding: got, as: UTF8.self), "later") + conn.close() + Darwin.close(peer) + } + + func testDeinitClosesDescriptor() throws { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + do { + let conn = Connection(fd: fds[0], remoteAddress: nil, localPort: nil) + XCTAssertNil(conn.remoteAddress) + XCTAssertNil(conn.localPort) + } + // DispatchIO closes the descriptor asynchronously; the peer then + // reads EOF. + var pfd = pollfd(fd: fds[1], events: Int16(POLLIN), revents: 0) + XCTAssertEqual(poll(&pfd, 1, 5000), 1) + XCTAssertEqual(peerRead(fds[1]), Data()) + Darwin.close(fds[1]) + } +} + +final class ServerLifecycleTests: XCTestCase { + func testConfigurationDefaults() { + let config = ServerConfiguration() + XCTAssertNil(config.identity) + XCTAssertEqual(config.relay, .automatic) + XCTAssertNil(config.derpMapURL) + XCTAssertFalse(config.embedRelayInToken) + XCTAssertEqual(config.allowedClients, []) + let custom = ServerConfiguration(relay: .region(302), embedRelayInToken: true) + XCTAssertEqual(custom.relay, .region(302)) + XCTAssertTrue(custom.embedRelayInToken) + } + + func testServerBeforeStart() async throws { + let identity = try Identity.generate() + let server = try TailcatServer(configuration: .init(identity: identity, relay: .region(302), allowedClients: [identity.publicKey])) + XCTAssertEqual(server.publicKey, identity.publicKey) + let token = await server.token + XCTAssertNil(token) + do { + _ = try await server.status() + XCTFail("status before start succeeded") + } catch { + XCTAssertEqual(error as? TailcatError, .notStarted) + } + let listener = try await server.listen(on: 80) + XCTAssertEqual(listener.port, 80) + // A port may be registered once until its listener is closed. + do { + _ = try await server.listen(on: 80) + XCTFail("listening twice on port 80 succeeded") + } catch TailcatError.internalError(let message) { + XCTAssertTrue(message.contains("80"), message) + } + try await server.allow(try Identity.generate().publicKey) + let catchAll = try await server.listen(on: 0) + XCTAssertEqual(catchAll.port, 0) + + // Closing the server ends a waiting accept. + let accepting = Task { try await listener.accept() } + try await Task.sleep(for: .milliseconds(50)) + await server.close() + do { + _ = try await accepting.value + XCTFail("accept returned after the server closed") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + await server.close() + do { + _ = try await server.start() + XCTFail("start after close succeeded") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + await listener.close() + await catchAll.close() + do { + _ = try await catchAll.accept() + XCTFail("accept after close succeeded") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + } + + func testListenerCloseWakesAccept() async throws { + let server = try TailcatServer() + let listener = try await server.listen(on: 8080) + let accepting = Task { try await listener.accept() } + try await Task.sleep(for: .milliseconds(50)) + await listener.close() + do { + _ = try await accepting.value + XCTFail("accept returned after the listener closed") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + // The port is free again. + let again = try await server.listen(on: 8080) + XCTAssertEqual(again.port, 8080) + await again.close() + await server.close() + } + + func testConnectionsStreamEndsOnClose() async throws { + let server = try TailcatServer() + let listener = try await server.listen(on: 9000) + let consuming = Task { () -> Int in + var count = 0 + for try await _ in listener.connections { + count += 1 + } + return count + } + try await Task.sleep(for: .milliseconds(50)) + await listener.close() + let count = try await consuming.value + XCTAssertEqual(count, 0) + await server.close() + } + + func testAcceptCancellation() async throws { + let server = try TailcatServer() + let listener = try await server.listen(on: 9001) + let accepting = Task { try await listener.accept() } + try await Task.sleep(for: .milliseconds(50)) + accepting.cancel() + do { + _ = try await accepting.value + XCTFail("accept returned after cancellation") + } catch { + XCTAssertTrue(error is CancellationError, "unexpected error \(error)") + } + // The listener is still usable afterwards. + let again = Task { try await listener.accept() } + try await Task.sleep(for: .milliseconds(50)) + await listener.close() + do { + _ = try await again.value + XCTFail("accept returned after close") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + await server.close() + } + + func testClientBeforeUse() async throws { + let token = try XCTUnwrap(ConnectionToken(rawValue: TokenTests.readmeToken)) + let identity = try Identity.generate() + let client = try TailcatClient(token: token, identity: identity, derpMapURL: URL(string: "https://example.invalid/derpmap.json")) + XCTAssertEqual(client.token, token) + let key = try await client.publicKey + XCTAssertEqual(key, identity.publicKey) + do { + _ = try await client.connect(port: 0) + XCTFail("connect to port 0 succeeded") + } catch { + XCTAssertEqual(error as? TailcatError, .invalidPort) + } + await client.close() + await client.close() + do { + _ = try await client.ping() + XCTFail("ping after close succeeded") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + } +} diff --git a/swift/Tests/TailcatKitTests/TokenTests.swift b/swift/Tests/TailcatKitTests/TokenTests.swift new file mode 100644 index 000000000..667dc0d5c --- /dev/null +++ b/swift/Tests/TailcatKitTests/TokenTests.swift @@ -0,0 +1,84 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import Foundation +import TailcatKit +import XCTest + +/// Offline tests of ConnectionToken, TokenInfo and NodePublicKey. +final class TokenTests: XCTestCase { + /// The token in the README, referencing DERP region 302. + static let readmeToken = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu" + /// The same server's resolved token, with the relay embedded. + static let resolvedToken = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA" + static let readmeKey = "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34" + + func testParseReadmeToken() throws { + let token = try XCTUnwrap(ConnectionToken(rawValue: Self.readmeToken)) + XCTAssertEqual(token.rawValue, Self.readmeToken) + XCTAssertEqual(token.description, Self.readmeToken) + let info = try token.parse() + XCTAssertEqual(info.serverPublicKey.rawValue, Self.readmeKey) + XCTAssertEqual(info.regionID, 302) + XCTAssertEqual(info.relayHosts, []) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: info.json) as? [String: Any]) + XCTAssertEqual(json["RegionID"] as? Int, 302) + XCTAssertEqual(json["ServerPublic"] as? String, Self.readmeKey) + } + + func testParseResolvedToken() throws { + let token = try XCTUnwrap(ConnectionToken(rawValue: Self.resolvedToken)) + let info = try token.parse() + XCTAssertEqual(info.serverPublicKey.rawValue, Self.readmeKey) + XCTAssertNil(info.regionID) + XCTAssertEqual(info.relayHosts, ["tc302a.ipn.dev"]) + } + + func testInvalidPrefixIsNil() { + XCTAssertNil(ConnectionToken(rawValue: "nope")) + XCTAssertNil(ConnectionToken(rawValue: "")) + XCTAssertNil(ConnectionToken(rawValue: "tc")) + XCTAssertNotNil(ConnectionToken(rawValue: "tcx")) + } + + func testGarbageThrowsInvalidToken() throws { + let token = try XCTUnwrap(ConnectionToken(rawValue: "tcgarbage")) + XCTAssertThrowsError(try token.parse()) { error in + guard case TailcatError.invalidToken(let message) = error else { + return XCTFail("unexpected error \(error)") + } + XCTAssertFalse(message.isEmpty) + } + } + + func testTokenCodable() throws { + let token = try XCTUnwrap(ConnectionToken(rawValue: Self.readmeToken)) + let encoded = try JSONEncoder().encode([token]) + XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "[\"\(Self.readmeToken)\"]") + let decoded = try JSONDecoder().decode([ConnectionToken].self, from: encoded) + XCTAssertEqual(decoded, [token]) + XCTAssertThrowsError(try JSONDecoder().decode(ConnectionToken.self, from: Data("\"nope\"".utf8))) + } + + func testNodePublicKeyValidation() throws { + let key = try XCTUnwrap(NodePublicKey(rawValue: Self.readmeKey)) + XCTAssertEqual(key.rawValue, Self.readmeKey) + XCTAssertEqual(key.description, Self.readmeKey) + XCTAssertNil(NodePublicKey(rawValue: "9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34")) + XCTAssertNil(NodePublicKey(rawValue: "nodekey:9c8d2e67")) + XCTAssertNil(NodePublicKey(rawValue: "nodekey:" + String(repeating: "g", count: 64))) + XCTAssertNotNil(NodePublicKey(rawValue: "nodekey:" + String(repeating: "A", count: 64))) + let encoded = try JSONEncoder().encode([key]) + XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "[\"\(Self.readmeKey)\"]") + XCTAssertEqual(try JSONDecoder().decode([NodePublicKey].self, from: encoded), [key]) + } + + func testClientRejectsMalformedToken() throws { + let token = try XCTUnwrap(ConnectionToken(rawValue: "tcgarbage")) + XCTAssertThrowsError(try TailcatClient(token: token)) { error in + guard case TailcatError.invalidToken = error else { + return XCTFail("unexpected error \(error)") + } + } + } +} From 785c16e646c81e04b9894b8a6a9341a5bf99a706 Mon Sep 17 00:00:00 2001 From: Josue Diaz Flores Date: Tue, 1 Sep 2026 23:14:46 -0700 Subject: [PATCH 3/6] libtailcat, swift: address review findings --- libtailcat/include/tailcat.h | 25 ++- libtailcat/libtailcat.go | 43 +++- libtailcat/libtailcat_test.go | 105 ++++++++++ swift/README.md | 5 +- swift/Sources/TailcatKit/Blocking.swift | 13 +- swift/Sources/TailcatKit/Connection.swift | 30 ++- .../Sources/TailcatKit/ConnectionToken.swift | 18 +- swift/Sources/TailcatKit/Listener.swift | 137 +++++++++---- swift/Sources/TailcatKit/TailcatClient.swift | 20 +- swift/Sources/TailcatKit/TailcatServer.swift | 10 +- .../Tests/TailcatKitTests/OfflineTests.swift | 193 ++++++++++++++++++ swift/Tests/TailcatKitTests/TokenTests.swift | 30 +++ 12 files changed, 554 insertions(+), 75 deletions(-) diff --git a/libtailcat/include/tailcat.h b/libtailcat/include/tailcat.h index 19689d58e..8a527b5d1 100644 --- a/libtailcat/include/tailcat.h +++ b/libtailcat/include/tailcat.h @@ -15,6 +15,8 @@ // EOF while its writes still reach you. close(2) tears the connection down. // On Apple platforms every descriptor handed out has SO_NOSIGPIPE set, so // writing to a dead connection fails with EPIPE instead of raising SIGPIPE. +// Every descriptor handed out is close-on-exec, so a child process the +// caller spawns does not inherit it and keep the connection alive. // // Every function is safe to call from any thread. Functions documented as // blocking (server start, client ping, path, dial, token resolve) do @@ -124,9 +126,16 @@ extern int tailcat_server_set_embed_relay(tailcat_handle sd, int embed); // tailcat_server_allow_client restricts the server to known clients: // nodekey is a client's public key in the "nodekey:" form that -// tailcat_client_public_key returns. Until any key is allowed, every -// client may connect; once one is, only allowed keys can. It may be called -// before or after start. +// tailcat_client_public_key returns. It may be called before or after +// start. +// +// The allow-list gates registration. Until any key is allowed, every +// client may register (its first ping is answered); once one is, only +// allowed clients can. A client that registered while the list was empty +// stays registered: its tunnel keeps working, its later pings still +// succeed and it can still open connections, until the server closes or +// the client restarts. To lock a server down from the start, allow its +// clients before tailcat_server_start. extern int tailcat_server_allow_client(tailcat_handle sd, const char* nodekey); // tailcat_server_listen registers port (1-65535) for incoming connections @@ -233,8 +242,10 @@ extern int tailcat_client_set_key(tailcat_handle cd, const char* key_json); extern int tailcat_client_set_derpmap_url(tailcat_handle cd, const char* url); // tailcat_client_public_key writes the client's node public key -// ("nodekey:") to buf, generating the ephemeral key first if none was -// set. Give this to the server's tailcat_server_allow_client. +// ("nodekey:") to buf: the key set with tailcat_client_set_key, or +// else the ephemeral key generated by tailcat_client_new. Give this to +// the server's tailcat_server_allow_client. It never blocks, not even +// while a ping or dial is bringing the client up on another thread. extern int tailcat_client_public_key(tailcat_handle cd, char* buf, size_t buflen); // tailcat_client_ping checks that the server is reachable and accepts this @@ -276,7 +287,9 @@ extern int tailcat_client_dial(tailcat_handle cd, int port, int timeout_ms, tail // tailcat_client_close closes every connection dialed through the client // on the Go side (reads on their descriptors return EOF; the caller still // close(2)s them), shuts the tunnel down and frees the handle. A second -// call returns EBADF. +// call returns EBADF. If a first ping, path or dial is bringing the +// client up on another thread, close waits for that bring-up (the DERP +// map fetch and relay setup) to finish first. // // Returns: // 0 - success diff --git a/libtailcat/libtailcat.go b/libtailcat/libtailcat.go index 60e223de3..bf615f268 100644 --- a/libtailcat/libtailcat.go +++ b/libtailcat/libtailcat.go @@ -54,7 +54,7 @@ var ( errNotStarted = errors.New("server not started") errClientStarted = errors.New("client already started; set options before its first use") errClientClosed = errors.New("client closed") - errKeyFixed = errors.New("client key already generated; set the key before asking for the public key") + errKeyFixed = errors.New("client key already reported; set the key before asking for the public key") ) // objects is the handle table: every tailcat_handle given to C maps to a @@ -759,7 +759,7 @@ func (s *server) listen(port uint16) (*listener, error) { // The tailcat_listener we return to C is one side of a socketpair(2). // Connections are pushed through it as they arrive, so C can poll(2) // the listener to learn when tailcat_accept won't block. - fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + fds, err := socketpair() if err != nil { return nil, err } @@ -926,6 +926,11 @@ func recvHandoff(lfd int) (connFd int, msg []byte, err error) { if err != nil { return -1, nil, err } + // recvmsg installs received descriptors without the close-on-exec + // flag (Darwin has no MSG_CMSG_CLOEXEC), so set it here. + for _, fd := range fds { + syscall.CloseOnExec(fd) + } if len(fds) != 1 { for _, fd := range fds { syscall.Close(fd) @@ -1020,7 +1025,7 @@ type conn struct { // newConn wraps the tunnel connection nc in a socketpair, returning the // conn and the descriptor for C. func newConn(owner *object, nc net.Conn) (*conn, int, error) { - fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + fds, err := socketpair() if err != nil { return nil, -1, err } @@ -1146,13 +1151,32 @@ func peerClosed(f *os.File) bool { return closed } +// socketpair returns a connected pair of AF_UNIX stream sockets, both +// close-on-exec: a child process the host forks must not inherit either +// end, or the connection would live on after the caller close(2)s its +// descriptor, and the Go side would never see the EOF or hangup that +// tears it down. Darwin has no SOCK_CLOEXEC, so the flag is set after +// the fact, under ForkLock like the net package does, so that a fork on +// the Go side cannot slip in between. +func socketpair() ([2]int, error) { + syscall.ForkLock.RLock() + defer syscall.ForkLock.RUnlock() + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + return fds, err + } + syscall.CloseOnExec(fds[0]) + syscall.CloseOnExec(fds[1]) + return fds, nil +} + // client is the state behind a client handle. type client struct { obj *object mu sync.Mutex cl *tailcat.Client - keyFixed bool // the key has been read or generated; set_key is too late + keyFixed bool // the key has been reported or used; set_key is too late started bool // the first ping, path or dial has happened closed bool } @@ -1193,7 +1217,12 @@ func TailcatClientNew(token *C.char) C.int { return 0 } o := &object{} - o.c = &client{obj: o, cl: &tailcat.Client{Server: blob}} + // The identity is generated here rather than lazily by tailcat so + // that public_key can report it without calling into tailcat.Client, + // whose first use (a ping or dial on another thread) holds its start + // lock across the DERP map fetch. tailcat adopts a Key that is set + // before first use, so the tunnel uses exactly this key. + o.c = &client{obj: o, cl: &tailcat.Client{Server: blob, Key: key.NewNode()}} return newHandle(o) } @@ -1238,8 +1267,8 @@ func TailcatClientPublicKey(cd C.int, buf *C.char, buflen C.size_t) C.int { return C.EBADF } c.mu.Lock() - c.keyFixed = true // PublicKey generates and pins the ephemeral key - pub := c.cl.PublicKey() + c.keyFixed = true // the key reported here must stay the one in use + pub := c.cl.Key.Public() c.mu.Unlock() return cstrOut(buf, buflen, pub.String()) } diff --git a/libtailcat/libtailcat_test.go b/libtailcat/libtailcat_test.go index aa8a12216..71a97d509 100644 --- a/libtailcat/libtailcat_test.go +++ b/libtailcat/libtailcat_test.go @@ -8,6 +8,8 @@ package main import ( "bufio" "encoding/json" + "net/http" + "net/http/httptest" "os" "strings" "sync" @@ -16,6 +18,7 @@ import ( "time" "github.com/tailscale/tailcat" + "golang.org/x/sys/unix" "tailscale.com/tstest/integration" "tailscale.com/types/key" "tailscale.com/types/logger" @@ -216,6 +219,20 @@ func connInfo(t *testing.T, l, c cInt) (remote string, localPort int) { return goString(&buf[0]), int(port) } +// expectCloseOnExec fails the test unless fd, a descriptor handed to C, is +// close-on-exec, so that a child process the host spawns does not inherit +// it and keep the connection alive. +func expectCloseOnExec(t *testing.T, what string, fd cInt) { + t.Helper() + flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) + if err != nil { + t.Fatalf("fcntl(F_GETFD) on the %s descriptor %d: %v", what, fd, err) + } + if flags&unix.FD_CLOEXEC == 0 { + t.Fatalf("the %s descriptor %d is not close-on-exec", what, fd) + } +} + // waitFor polls cond for up to timeout. func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) { t.Helper() @@ -242,6 +259,7 @@ func TestEndToEnd(t *testing.T) { setServerRegionForTest(sd, reg) var l80, lAny cInt check(t, "listen 80", sd, TailcatServerListen(sd, 80, &l80)) + expectCloseOnExec(t, "listener", l80) if rc := TailcatServerListen(sd, 80, &lAny); rc != -1 || errmsg(sd) == "" { t.Fatalf("second listen on port 80: rc=%d, errmsg=%q; want -1 with a message", rc, errmsg(sd)) } @@ -343,8 +361,10 @@ func TestEndToEnd(t *testing.T) { // Dial, accept, exchange data both ways. var c1 cInt check(t, "dial 80", cd, TailcatClientDial(cd, 80, 10000, &c1)) + expectCloseOnExec(t, "dialed connection", c1) writeAll(t, c1, "hello\n") s1 := accept(t, l80, 10*time.Second) + expectCloseOnExec(t, "accepted connection", s1) if got := readLine(t, s1); got != "hello\n" { t.Fatalf("server read %q; want hello", got) } @@ -601,3 +621,88 @@ func TestServerConfig(t *testing.T) { t.Fatalf("start on a bogus handle: rc=%d; want EBADF", rc) } } + +// TestClientPublicKeyDoesNotBlock checks that tailcat_client_public_key +// answers right away while another thread's first-use ping is fetching +// the DERP map, instead of waiting behind tailcat's start lock, and that +// the client's key can no longer change once it has been reported. +func TestClientPublicKeyDoesNotBlock(t *testing.T) { + // A DERP map server that stalls until released, then fails. + entered := make(chan struct{}) + release := make(chan struct{}) + var enterOnce sync.Once + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + enterOnce.Do(func() { close(entered) }) + select { + case <-release: + case <-r.Context().Done(): + } + http.Error(w, "no DERP map here", http.StatusServiceUnavailable) + })) + defer srv.Close() + + // A token that references a DERP map region by ID, so the client + // must fetch the map, and carries a disco key (the README token + // predates those, and a client refuses to start without one). + pk := tailcat.NewPrivateKey() + pk.Public.RegionID = readmeTokenRegn + js, err := json.Marshal(pk) + if err != nil { + t.Fatal(err) + } + cjs := cString(string(js)) + var tok *cChar + if e := TailcatKeyToken(cjs, &tok); e != nil { + t.Fatalf("key_token: %s", gostr(e)) + } + cFree(cjs) + ctoken := cString(gostr(tok)) + defer cFree(ctoken) + cd := TailcatClientNew(ctoken) + if cd == 0 { + t.Fatal("client_new returned 0 for a key_token token") + } + check(t, "set_logfd", cd, TailcatSetLogFD(cd, -1)) + curl := cString(srv.URL + "/derpmap.json") + check(t, "set_derpmap_url", cd, TailcatClientSetDERPMapURL(cd, curl)) + cFree(curl) + + pingDone := make(chan cInt, 1) + go func() { pingDone <- TailcatClientPing(cd, 5000, nil) }() + select { + case <-entered: + case rc := <-pingDone: + t.Fatalf("the ping returned rc=%d before fetching the DERP map: %s", rc, errmsg(cd)) + case <-time.After(10 * time.Second): + t.Fatal("the ping never fetched the DERP map") + } + + start := time.Now() + pub := readBuf(t, "public_key", cd, func(b *cChar, n cSize) cInt { return TailcatClientPublicKey(cd, b, n) }) + if d := time.Since(start); d > time.Second { + t.Fatalf("public_key took %v with a ping in flight; want it not to wait for the DERP map fetch", d) + } + if !strings.HasPrefix(pub, "nodekey:") { + t.Fatalf("public_key = %q", pub) + } + close(release) + if rc := <-pingDone; rc != -1 { + t.Fatalf("ping with no DERP map: rc=%d; want -1", rc) + } + t.Logf("ping with no DERP map: %s", errmsg(cd)) + + // The key was reported and the client used: set_key is too late. + other, err := json.Marshal(tailcat.NewPrivateKey()) + if err != nil { + t.Fatal(err) + } + cother := cString(string(other)) + if rc := TailcatClientSetKey(cd, cother); rc != -1 { + t.Fatalf("set_key after use: rc=%d; want -1", rc) + } + cFree(cother) + if got := readBuf(t, "public_key again", cd, func(b *cChar, n cSize) cInt { return TailcatClientPublicKey(cd, b, n) }); got != pub { + t.Fatalf("public_key changed from %s to %s", pub, got) + } + check(t, "client_close", cd, TailcatClientClose(cd)) +} diff --git a/swift/README.md b/swift/README.md index 14c15349c..af344dd68 100644 --- a/swift/README.md +++ b/swift/README.md @@ -77,7 +77,10 @@ let long = try await token.resolved() // self-contained form, relay Restrict a server to known clients with `ServerConfiguration.allowedClients` or `TailcatServer.allow(_:)`, and give clients an `Identity` so their -public key is stable. With a saved identity, `RelaySelection.automatic` +public key is stable. The allow list gates registration: a client that +registered while it was empty stays connected, so list the clients before +start to lock a server down from the beginning. With a saved identity, +`RelaySelection.automatic` keeps the relay recorded in the key file (a fixed region keeps the token stable across restarts); `.region(id)` and `.hosts([...])` override it. diff --git a/swift/Sources/TailcatKit/Blocking.swift b/swift/Sources/TailcatKit/Blocking.swift index e713f2262..dc002b13a 100644 --- a/swift/Sources/TailcatKit/Blocking.swift +++ b/swift/Sources/TailcatKit/Blocking.swift @@ -29,15 +29,20 @@ enum Blocking { extension Duration { /// The duration in whole milliseconds as the C layer takes timeouts, - /// clamped to Int32. Zero means no limit beyond tailcat's own. + /// rounded up, since a positive timeout must never become 0, which + /// means no limit, and clamped to Int32. Zero or less means no limit + /// beyond tailcat's own. var millisecondsForC: Int32 { - let (seconds, attoseconds) = components - if seconds < 0 { + guard self > .zero else { return 0 } + let (seconds, attoseconds) = components if seconds >= Int64(Int32.max) / 1000 { return Int32.max } - return Int32(clamping: seconds * 1000 + attoseconds / 1_000_000_000_000_000) + let attosecondsPerMillisecond: Int64 = 1_000_000_000_000_000 + let (wholeMilliseconds, rest) = attoseconds.quotientAndRemainder(dividingBy: attosecondsPerMillisecond) + let milliseconds = seconds * 1000 + wholeMilliseconds + (rest > 0 ? 1 : 0) + return Int32(clamping: milliseconds) } } diff --git a/swift/Sources/TailcatKit/Connection.swift b/swift/Sources/TailcatKit/Connection.swift index 87531cf7a..81069d541 100644 --- a/swift/Sources/TailcatKit/Connection.swift +++ b/swift/Sources/TailcatKit/Connection.swift @@ -42,9 +42,13 @@ public final class Connection: Sendable { var reading = false var readRequested = 0 var readDelivered = 0 - /// The receive waiting for data, if any. + /// The receive waiting for data, if any, with the serial of the + /// receive() call that registered it, so that a cancellation + /// handler acts only on its own registration. var receiver: CheckedContinuation? var receiverMax = 0 + var receiverSerial: UInt64 = 0 + var lastSerial: UInt64 = 0 } /// Wraps the descriptor, which the connection now owns and closes @@ -110,16 +114,20 @@ public final class Connection: Sendable { throw TailcatError.internalError("receive needs a positive maxLength") } try Task.checkCancellation() + let serial = state.withLock { s -> UInt64 in + s.lastSerial += 1 + return s.lastSerial + } return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in enum Action { case resume(Data) - case fail(TailcatError) + case fail(any Error) case wait(startRead: Bool) } let action: Action = state.withLock { s in if s.closed { - return .fail(.closed) + return .fail(TailcatError.closed) } if !s.buffer.isEmpty { return .resume(Self.take(&s, maxLength)) @@ -131,10 +139,18 @@ public final class Connection: Sendable { return .resume(Data()) } if s.receiver != nil { - return .fail(.internalError("a receive is already in progress")) + return .fail(TailcatError.internalError("a receive is already in progress")) + } + // A cancellation that landed before this point ran the + // handler with nothing registered yet; checking under + // the lock closes that window, since any later one + // finds the registration. + if Task.isCancelled { + return .fail(CancellationError()) } s.receiver = continuation s.receiverMax = maxLength + s.receiverSerial = serial if s.reading { return .wait(startRead: false) } @@ -155,8 +171,12 @@ public final class Connection: Sendable { } } } onCancel: { + // Only this call's registration: another receive may be the + // one waiting. let receiver = state.withLock { s -> CheckedContinuation? in - let r = s.receiver + guard s.receiverSerial == serial, let r = s.receiver else { + return nil + } s.receiver = nil return r } diff --git a/swift/Sources/TailcatKit/ConnectionToken.swift b/swift/Sources/TailcatKit/ConnectionToken.swift index 1b517f512..d3ebaa8f3 100644 --- a/swift/Sources/TailcatKit/ConnectionToken.swift +++ b/swift/Sources/TailcatKit/ConnectionToken.swift @@ -89,8 +89,12 @@ public struct ConnectionToken: Sendable, Hashable, Codable, RawRepresentable, Cu /// details embedded so clients need no DERP map fetch (the same as /// "tailcat resolve"). A token that already embeds them comes back /// unchanged. The DERP map is fetched from derpMapURL, or the default - /// map when nil. The work runs off the Swift concurrency threads. A - /// zero timeout means no limit beyond the fetch's own. + /// map when nil. The work runs off the Swift concurrency threads. The + /// timeout is rounded up to whole milliseconds; zero means no limit + /// beyond the fetch's own. Throws TailcatError.invalidToken for a + /// malformed token, TailcatError.timeout when time runs out, and + /// otherwise the fetch's error, such as + /// TailcatError.posix(ECONNREFUSED, _) or TailcatError.internalError. public func resolved(derpMapURL: URL? = nil, timeout: Duration = .seconds(10)) async throws -> ConnectionToken { let token = rawValue let url = derpMapURL?.absoluteString @@ -105,10 +109,14 @@ public struct ConnectionToken: Sendable, Hashable, Codable, RawRepresentable, Cu } if let message = CStrings.take(err) { free(out) - switch TailcatError.classify(message: message) { - case .timeout: throw TailcatError.timeout - default: throw TailcatError.invalidToken(message) + // Resolving fails either because the token is malformed, + // which parse() detects offline, or because the DERP map + // could not be fetched, which says nothing about the + // token. + if (try? self.parse()) == nil { + throw TailcatError.invalidToken(message) } + throw TailcatError.classify(message: message) } guard let text = CStrings.take(out) else { throw TailcatError.internalError("tailcat_token_resolve returned no token") diff --git a/swift/Sources/TailcatKit/Listener.swift b/swift/Sources/TailcatKit/Listener.swift index b240169b3..ce035a8a0 100644 --- a/swift/Sources/TailcatKit/Listener.swift +++ b/swift/Sources/TailcatKit/Listener.swift @@ -72,11 +72,32 @@ final class ListenerCore: Sendable { private struct State: Sendable { var fd: Int32 var closed = false - /// The source armed for the accept in progress, if any. While it - /// is set, its cancel handler owns closing the descriptor. + /// The source armed for the accept in progress, if any. Its cancel + /// handler is the one place the source is retired and the waiter + /// resumed, so a following accept never finds a source that is + /// still winding down; while it is set, that handler also owns + /// closing the descriptor. var source: SourceRef? - /// The accept waiting for readability, if any. + /// The accept waiting for readability, set and cleared together + /// with source. var waiter: CheckedContinuation? + /// What happened to the armed source: the descriptor became + /// readable, or the waiting task was cancelled. + var readable = false + var taskCancelled = false + } + + /// What one waitReadable call has armed, for its cancellation handler, + /// which must act on that arm alone, and whether the task was + /// cancelled before the arm was recorded. + private struct Registration: Sendable { + var ref: SourceRef? + var cancelled = false + } + + /// Why an armed source was retired. + private enum Outcome { + case readable, cancelled, closed } private let state: OSAllocatedUnfairLock @@ -90,6 +111,7 @@ final class ListenerCore: Sendable { /// tailcat_accept will not block. func waitReadable() async throws { try Task.checkCancellation() + let registration = OSAllocatedUnfairLock(initialState: Registration()) try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let armed: Result = state.withLock { s in @@ -102,6 +124,8 @@ final class ListenerCore: Sendable { let ref = SourceRef(source: DispatchSource.makeReadSource(fileDescriptor: s.fd, queue: Self.queue)) s.source = ref s.waiter = continuation + s.readable = false + s.taskCancelled = false return .success(ref) } switch armed { @@ -109,46 +133,89 @@ final class ListenerCore: Sendable { continuation.resume(throwing: error) case .success(let ref): ref.source.setEventHandler { [state, ref] in - let waiter = state.withLock { s -> CheckedContinuation? in - let w = s.waiter - s.waiter = nil - return w + // Note the readability and retire the source; its + // cancel handler resumes the waiter. + state.withLock { s in + if let current = s.source, current.source === ref.source { + s.readable = true + } } ref.source.cancel() - waiter?.resume() } ref.source.setCancelHandler { [state, ref] in - // The source is unregistered now, so the - // descriptor may be closed if close() asked for it. - let (fdToClose, waiter) = state.withLock { s -> (Int32, CheckedContinuation?) in - var fdToClose: Int32 = -1 - if let current = s.source, current.source === ref.source { - s.source = nil - if s.closed { - fdToClose = s.fd - s.fd = -1 - } + // The source is unregistered now: retire it, close + // the descriptor if close() asked for that, and + // resume the waiter with what happened. + let (fdToClose, waiter, outcome) = state.withLock { s -> (Int32, CheckedContinuation?, Outcome) in + guard let current = s.source, current.source === ref.source else { + return (-1, nil, .closed) } + s.source = nil let w = s.waiter s.waiter = nil - return (fdToClose, w) + var fdToClose: Int32 = -1 + if s.closed { + fdToClose = s.fd + s.fd = -1 + } + let outcome: Outcome + if s.taskCancelled { + outcome = .cancelled + } else if s.closed || !s.readable { + outcome = .closed + } else { + outcome = .readable + } + return (fdToClose, w, outcome) } if fdToClose >= 0 { _ = Darwin.close(fdToClose) } - waiter?.resume(throwing: TailcatError.closed) + switch outcome { + case .readable: + waiter?.resume() + case .cancelled: + waiter?.resume(throwing: CancellationError()) + case .closed: + waiter?.resume(throwing: TailcatError.closed) + } } ref.source.activate() + // Record the arm for the cancellation handler. If the + // task was cancelled before now, that handler found + // nothing to act on, so act here. + let cancelledMeanwhile = registration.withLock { r -> Bool in + r.ref = ref + return r.cancelled + } + if cancelledMeanwhile { + cancelWait(ref) + } } } } onCancel: { - let (ref, waiter) = state.withLock { s -> (SourceRef?, CheckedContinuation?) in - let w = s.waiter - s.waiter = nil - return (s.source, w) + let ref = registration.withLock { r -> SourceRef? in + r.cancelled = true + return r.ref + } + if let ref { + cancelWait(ref) } - ref?.source.cancel() - waiter?.resume(throwing: CancellationError()) + } + } + + /// Ends the wait armed with ref, if it is still the current one, with + /// CancellationError: the source's cancel handler resumes the waiter. + private func cancelWait(_ ref: SourceRef) { + let current = state.withLock { s -> Bool in + guard let current = s.source, current.source === ref.source else { + return false + } + s.taskCancelled = true + return true + } + if current { + ref.source.cancel() } } @@ -189,23 +256,22 @@ final class ListenerCore: Sendable { ) } - /// Closes the listener once: wakes a waiting accept with - /// TailcatError.closed and closes the descriptor, directly or from the - /// armed source's cancel handler. + /// Closes the listener once: a waiting accept ends with + /// TailcatError.closed, and the descriptor is closed, directly or from + /// the armed source's cancel handler once the source has let go of + /// it. func close() { - let (fdToClose, ref, waiter) = state.withLock { s -> (Int32, SourceRef?, CheckedContinuation?) in + let (fdToClose, ref) = state.withLock { s -> (Int32, SourceRef?) in if s.closed { - return (-1, nil, nil) + return (-1, nil) } s.closed = true - let w = s.waiter - s.waiter = nil if let ref = s.source { - return (-1, ref, w) + return (-1, ref) } let fd = s.fd s.fd = -1 - return (fd, nil, w) + return (fd, nil) } if let ref { ref.source.cancel() @@ -213,6 +279,5 @@ final class ListenerCore: Sendable { if fdToClose >= 0 { _ = Darwin.close(fdToClose) } - waiter?.resume(throwing: TailcatError.closed) } } diff --git a/swift/Sources/TailcatKit/TailcatClient.swift b/swift/Sources/TailcatKit/TailcatClient.swift index 72302a04d..50c77f652 100644 --- a/swift/Sources/TailcatKit/TailcatClient.swift +++ b/swift/Sources/TailcatKit/TailcatClient.swift @@ -57,9 +57,10 @@ public actor TailcatClient { } } - /// The client's node public key, "nodekey:", generating the - /// ephemeral key if no identity was given. Give it to the server's - /// allow(_:). + /// The client's node public key, "nodekey:": that of the + /// identity given at init, or else of the ephemeral key generated + /// then. Give it to the server's allow(_:). It never blocks, not even + /// while a ping or connect is bringing the client up. public var publicKey: NodePublicKey { get throws { let h = try activeHandle() @@ -76,8 +77,9 @@ public actor TailcatClient { /// returns the relay round trip (tailcat_client_ping, off-thread). /// Each call sends one probe: a server that does not allow this /// client, or one still connecting to its relay right after starting, - /// shows up as TailcatError.timeout, which is worth a retry. A zero - /// timeout means no limit beyond tailcat's own. + /// shows up as TailcatError.timeout, which is worth a retry. The + /// timeout is rounded up to whole milliseconds; zero means no limit + /// beyond tailcat's own. public func ping(timeout: Duration = .seconds(10)) async throws -> Duration { let h = try activeHandle() let ms = timeout.millisecondsForC @@ -91,8 +93,9 @@ public actor TailcatClient { /// Reports how packets reach the server (tailcat_client_path_json, /// off-thread): a direct path, or the relay carrying them. Calling it - /// repeatedly nudges direct path discovery along. A zero timeout means - /// no limit beyond tailcat's own. + /// repeatedly nudges direct path discovery along. The timeout is + /// rounded up to whole milliseconds; zero means no limit beyond + /// tailcat's own. public func path(timeout: Duration = .seconds(10)) async throws -> PathInfo { let h = try activeHandle() let ms = timeout.millisecondsForC @@ -110,7 +113,8 @@ public actor TailcatClient { /// Opens a TCP connection to port on the server (tailcat_client_dial, /// off-thread). Throws TailcatError.invalidPort for port 0 and /// TailcatError.posix(ECONNREFUSED, _) when nothing listens on the - /// port. A zero timeout means no limit beyond tailcat's own. + /// port. The timeout is rounded up to whole milliseconds; zero means + /// no limit beyond tailcat's own. public func connect(port: UInt16, timeout: Duration = .seconds(15)) async throws -> Connection { guard port != 0 else { throw TailcatError.invalidPort diff --git a/swift/Sources/TailcatKit/TailcatServer.swift b/swift/Sources/TailcatKit/TailcatServer.swift index a33f95b86..db12e1136 100644 --- a/swift/Sources/TailcatKit/TailcatServer.swift +++ b/swift/Sources/TailcatKit/TailcatServer.swift @@ -144,9 +144,13 @@ public actor TailcatServer { return token } - /// Allows a client by public key, before or after start. Until any - /// key is allowed every client may connect; once one is, only allowed - /// keys can. + /// Allows a client by public key, before or after start. The allow + /// list gates registration: until any key is allowed every client may + /// register; once one is, only allowed clients can. A client that + /// registered while the list was empty stays registered, its pings + /// keep succeeding and it can still open connections until the server + /// closes or the client restarts; to lock a server down from the + /// start, list its clients in ServerConfiguration.allowedClients. public func allow(_ key: NodePublicKey) throws { let h = try activeHandle() try TailcatError.check(key.rawValue.withCString { tailcat_server_allow_client(h, $0) }, handle: h) diff --git a/swift/Tests/TailcatKitTests/OfflineTests.swift b/swift/Tests/TailcatKitTests/OfflineTests.swift index db143885d..4bec847c0 100644 --- a/swift/Tests/TailcatKitTests/OfflineTests.swift +++ b/swift/Tests/TailcatKitTests/OfflineTests.swift @@ -19,6 +19,24 @@ func peerRead(_ fd: Int32, max: Int = 4096) -> Data? { return n < 0 ? nil : Data(buf[0..(_ seconds: Int = 10, _ body: @escaping @Sendable () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await body() } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + throw TailcatError.timeout + } + guard let result = try await group.next() else { + throw TailcatError.timeout + } + group.cancelAll() + return result + } +} + /// Offline tests of Connection over a plain socketpair, and of the server /// and listener life cycle before start. final class ConnectionTests: XCTestCase { @@ -162,6 +180,60 @@ final class ConnectionTests: XCTestCase { Darwin.close(peer) } + func testCancellationAtEveryStageOfReceive() async throws { + let (conn, peer) = try makePair() + // Cancel at varying points of entering receive(): before it runs, + // while it registers, and once it waits. None may hang or return + // data, and the connection stays usable. + try await withDeadline(30) { + for i in 0..<300 { + let waiting = Task { try await conn.receive() } + switch i % 3 { + case 1: await Task.yield() + case 2: try await Task.sleep(for: .microseconds(50)) + default: break + } + waiting.cancel() + do { + _ = try await waiting.value + XCTFail("receive \(i) returned for a cancelled task") + } catch { + XCTAssertTrue(error is CancellationError, "receive \(i): unexpected error \(error)") + } + } + } + peerWrite(peer, "later") + let got = try await conn.receive() + XCTAssertEqual(String(decoding: got, as: UTF8.self), "later") + conn.close() + Darwin.close(peer) + } + + func testConcurrentReceiveIsRefusedWithoutDisturbingTheFirst() async throws { + let (conn, peer) = try makePair() + let first = Task { try await conn.receive() } + try await Task.sleep(for: .milliseconds(20)) + // A second receive is refused; cancelling it must not end the + // first, whose registration it does not own. + for _ in 0..<100 { + let second = Task { try await conn.receive() } + second.cancel() + do { + _ = try await second.value + XCTFail("a second receive succeeded") + } catch is CancellationError { + } catch TailcatError.internalError { + } catch { + XCTFail("unexpected error \(error)") + } + } + peerWrite(peer, "data") + let got = try await withDeadline { try await first.value } + XCTAssertEqual(String(decoding: got, as: UTF8.self), "data") + conn.close() + Darwin.close(peer) + } + func testDeinitClosesDescriptor() throws { var fds: [Int32] = [-1, -1] XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) @@ -328,3 +400,124 @@ final class ServerLifecycleTests: XCTestCase { } } } + +/// Offline tests of ListenerCore, the readiness machinery behind +/// Listener, over a plain socketpair. +final class ListenerCoreTests: XCTestCase { + /// A core over one end of a socketpair, that end, and the peer. + func makeCore() -> (ListenerCore, Int32, Int32) { + var fds: [Int32] = [-1, -1] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + return (ListenerCore(fd: fds[0]), fds[0], fds[1]) + } + + /// Each wait arms a fresh read source. Re-arming right after the + /// previous source fired must never be mistaken for a concurrent + /// accept. + func testRepeatedWaitsDoNotCollide() async throws { + let (core, fd, peer) = makeCore() + try await withDeadline(60) { + for i in 0..<5000 { + peerWrite(peer, "x") + do { + try await core.waitReadable() + } catch { + XCTFail("wait \(i) failed: \(error)") + return + } + var byte: UInt8 = 0 + XCTAssertEqual(Darwin.read(fd, &byte, 1), 1) + } + } + core.close() + Darwin.close(peer) + } + + func testCancellationAtEveryStageOfWait() async throws { + let (core, fd, peer) = makeCore() + try await withDeadline(30) { + for i in 0..<300 { + let waiting = Task { try await core.waitReadable() } + switch i % 3 { + case 1: await Task.yield() + case 2: try await Task.sleep(for: .microseconds(50)) + default: break + } + waiting.cancel() + do { + try await waiting.value + XCTFail("wait \(i) returned for a cancelled task") + } catch { + XCTAssertTrue(error is CancellationError, "wait \(i): unexpected error \(error)") + } + } + // The core is still usable afterwards. + peerWrite(peer, "x") + try await core.waitReadable() + var byte: UInt8 = 0 + XCTAssertEqual(Darwin.read(fd, &byte, 1), 1) + } + core.close() + Darwin.close(peer) + } + + func testConcurrentWaitIsRefusedWithoutDisturbingTheFirst() async throws { + let (core, fd, peer) = makeCore() + let first = Task { try await core.waitReadable() } + try await Task.sleep(for: .milliseconds(20)) + for _ in 0..<100 { + let second = Task { try await core.waitReadable() } + second.cancel() + do { + try await second.value + XCTFail("a second wait succeeded") + } catch is CancellationError { + } catch TailcatError.internalError { + } catch { + XCTFail("unexpected error \(error)") + } + } + peerWrite(peer, "x") + try await withDeadline { try await first.value } + var byte: UInt8 = 0 + XCTAssertEqual(Darwin.read(fd, &byte, 1), 1) + core.close() + Darwin.close(peer) + } + + func testCloseEndsWait() async throws { + let (core, _, peer) = makeCore() + let waiting = Task { try await core.waitReadable() } + try await Task.sleep(for: .milliseconds(20)) + core.close() + do { + try await withDeadline { try await waiting.value } + XCTFail("wait returned after close") + } catch { + XCTAssertEqual(error as? TailcatError, .closed) + } + // The descriptor is closed once the source has let go of it: the + // peer reads EOF. + var pfd = pollfd(fd: peer, events: Int16(POLLIN), revents: 0) + XCTAssertEqual(poll(&pfd, 1, 5000), 1) + XCTAssertEqual(peerRead(peer), Data()) + Darwin.close(peer) + } +} + +/// Tests of the Duration to C timeout conversion. +final class DurationTests: XCTestCase { + func testMillisecondsForCRoundsUp() { + XCTAssertEqual(Duration.zero.millisecondsForC, 0) + XCTAssertEqual(Duration.seconds(-1).millisecondsForC, 0) + XCTAssertEqual(Duration.microseconds(-500).millisecondsForC, 0) + // A positive timeout never becomes 0, which means no limit. + XCTAssertEqual(Duration.nanoseconds(1).millisecondsForC, 1) + XCTAssertEqual(Duration.microseconds(500).millisecondsForC, 1) + XCTAssertEqual(Duration.milliseconds(1).millisecondsForC, 1) + XCTAssertEqual((Duration.milliseconds(1) + .nanoseconds(1)).millisecondsForC, 2) + XCTAssertEqual(Duration.milliseconds(1500).millisecondsForC, 1500) + XCTAssertEqual(Duration.seconds(3).millisecondsForC, 3000) + XCTAssertEqual(Duration.seconds(1_000_000_000).millisecondsForC, Int32.max) + } +} diff --git a/swift/Tests/TailcatKitTests/TokenTests.swift b/swift/Tests/TailcatKitTests/TokenTests.swift index 667dc0d5c..4238537cc 100644 --- a/swift/Tests/TailcatKitTests/TokenTests.swift +++ b/swift/Tests/TailcatKitTests/TokenTests.swift @@ -73,6 +73,36 @@ final class TokenTests: XCTestCase { XCTAssertEqual(try JSONDecoder().decode([NodePublicKey].self, from: encoded), [key]) } + /// A DERP map that cannot be fetched is a network failure, not a bad + /// token: only a malformed token is reported as invalidToken. + func testResolveMapsNetworkFailures() async throws { + // Nothing listens on port 9 of the loopback interface, so the + // fetch is refused outright. + let refused = try XCTUnwrap(URL(string: "http://127.0.0.1:9/derpmap.json")) + let token = try XCTUnwrap(ConnectionToken(rawValue: Self.readmeToken)) + do { + _ = try await token.resolved(derpMapURL: refused, timeout: .seconds(5)) + XCTFail("resolving against a refused DERP map URL succeeded") + } catch TailcatError.invalidToken(let message) { + XCTFail("a network failure was reported as an invalid token: \(message)") + } catch let error as TailcatError { + if case .posix(let code, _) = error { + XCTAssertEqual(code, ECONNREFUSED) + } + } + // A malformed token is invalid whatever the map. + let garbage = try XCTUnwrap(ConnectionToken(rawValue: "tcgarbage")) + do { + _ = try await garbage.resolved(derpMapURL: refused, timeout: .seconds(5)) + XCTFail("resolving a malformed token succeeded") + } catch TailcatError.invalidToken { + } + // A token that embeds its relay resolves to itself, offline. + let embedded = try XCTUnwrap(ConnectionToken(rawValue: Self.resolvedToken)) + let same = try await embedded.resolved(derpMapURL: refused, timeout: .seconds(5)) + XCTAssertEqual(same, embedded) + } + func testClientRejectsMalformedToken() throws { let token = try XCTUnwrap(ConnectionToken(rawValue: "tcgarbage")) XCTAssertThrowsError(try TailcatClient(token: token)) { error in From 1166590a6490182ce281bbd05fe34d6cd3776865 Mon Sep 17 00:00:00 2001 From: Josue Diaz Flores Date: Tue, 1 Sep 2026 23:26:14 -0700 Subject: [PATCH 4/6] libtailcat: exclude the package when cgo is disabled With CGO_ENABLED=0 the file that imports "C" drops out of the build but the helper files did not, leaving a package main with no main function. Constrain every file on cgo so the package is skipped instead. --- libtailcat/cgo_types.go | 2 +- libtailcat/libtailcat.go | 2 +- libtailcat/libtailcat_test.go | 2 +- libtailcat/sigpipe_darwin.go | 2 +- libtailcat/sigpipe_other.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libtailcat/cgo_types.go b/libtailcat/cgo_types.go index 227f1ecb9..9ccc54633 100644 --- a/libtailcat/cgo_types.go +++ b/libtailcat/cgo_types.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -//go:build unix +//go:build unix && cgo package main diff --git a/libtailcat/libtailcat.go b/libtailcat/libtailcat.go index bf615f268..9ea34ad98 100644 --- a/libtailcat/libtailcat.go +++ b/libtailcat/libtailcat.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -//go:build unix +//go:build unix && cgo // Command libtailcat is the Go side of the libtailcat C library, built // with -buildmode=c-archive. See include/tailcat.h for the C API and diff --git a/libtailcat/libtailcat_test.go b/libtailcat/libtailcat_test.go index 71a97d509..27210534a 100644 --- a/libtailcat/libtailcat_test.go +++ b/libtailcat/libtailcat_test.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -//go:build unix +//go:build unix && cgo package main diff --git a/libtailcat/sigpipe_darwin.go b/libtailcat/sigpipe_darwin.go index 494133f22..632f1b682 100644 --- a/libtailcat/sigpipe_darwin.go +++ b/libtailcat/sigpipe_darwin.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -//go:build darwin +//go:build darwin && cgo package main diff --git a/libtailcat/sigpipe_other.go b/libtailcat/sigpipe_other.go index 79318830a..5df17e857 100644 --- a/libtailcat/sigpipe_other.go +++ b/libtailcat/sigpipe_other.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -//go:build unix && !darwin +//go:build unix && !darwin && cgo package main From b9133b80b32e9e022d144a5e067af47ef93746ff Mon Sep 17 00:00:00 2001 From: Josue Diaz Flores Date: Wed, 2 Sep 2026 19:43:42 -0700 Subject: [PATCH 5/6] libtailcat, swift: rename token to addr to match upstream --- libtailcat/README.md | 16 +-- libtailcat/include/tailcat.h | 80 ++++++------ libtailcat/libtailcat.go | 90 +++++++------- libtailcat/libtailcat_test.go | 99 +++++++-------- libtailcat/tailcat.c | 30 ++--- swift/README.md | 34 +++--- swift/Sources/TailcatDemo/main.swift | 36 +++--- swift/Sources/TailcatKit/Blocking.swift | 2 +- swift/Sources/TailcatKit/Identity.swift | 16 +-- swift/Sources/TailcatKit/Relay.swift | 14 +-- ...ectionToken.swift => TailcatAddress.swift} | 90 +++++++------- swift/Sources/TailcatKit/TailcatClient.swift | 28 ++--- swift/Sources/TailcatKit/TailcatError.swift | 10 +- swift/Sources/TailcatKit/TailcatServer.swift | 28 ++--- .../Tests/TailcatKitTests/AddressTests.swift | 114 ++++++++++++++++++ .../Tests/TailcatKitTests/EndToEndTests.swift | 12 +- .../Tests/TailcatKitTests/IdentityTests.swift | 14 +-- .../Tests/TailcatKitTests/OfflineTests.swift | 16 +-- swift/Tests/TailcatKitTests/TokenTests.swift | 114 ------------------ 19 files changed, 423 insertions(+), 420 deletions(-) rename swift/Sources/TailcatKit/{ConnectionToken.swift => TailcatAddress.swift} (65%) create mode 100644 swift/Tests/TailcatKitTests/AddressTests.swift delete mode 100644 swift/Tests/TailcatKitTests/TokenTests.swift diff --git a/libtailcat/README.md b/libtailcat/README.md index fdc21ebb3..0078c8b92 100644 --- a/libtailcat/README.md +++ b/libtailcat/README.md @@ -35,7 +35,7 @@ system frameworks the Go runtime uses on Darwin, typically Every function is safe to call from any thread. Handle functions return 0 on success, `EBADF` for a bad handle, `ERANGE` for a too-small output buffer and -1 for other errors, whose text `tailcat_errmsg` returns. -Blocking calls (server start, client ping, path, dial, token resolve) +Blocking calls (server start, client ping, path, dial, address resolve) do network work; keep them off UI threads. A server: @@ -53,8 +53,8 @@ if (tailcat_server_start(sd) != 0) { // blocks: DERP map, latency check, r tailcat_errmsg(sd, err, sizeof err); // ... } -char token[512]; -tailcat_server_token(sd, token, sizeof token); // give this to clients +char addr[512]; +tailcat_server_addr(sd, addr, sizeof addr); // the tailcat address; give this to clients for (;;) { struct pollfd pfd = {.fd = ln, .events = POLLIN}; @@ -73,7 +73,7 @@ tailcat_server_close(sd); A client: ```c -tailcat_handle cd = tailcat_client_new(token); // 0 if the token is malformed +tailcat_handle cd = tailcat_client_new(addr); // 0 if the address is malformed double ms; tailcat_client_ping(cd, 10000, &ms); // blocks; brings the tunnel up tailcat_conn c; @@ -86,8 +86,8 @@ tailcat_client_close(cd); ``` Connections are one end of a socketpair pumped by Go, so they behave like -sockets; on Apple platforms they have `SO_NOSIGPIPE` set. Keys and tokens -can be handled without a handle: `tailcat_key_generate`, -`tailcat_key_public`, `tailcat_key_token`, `tailcat_token_parse` and -`tailcat_token_resolve` return `NULL` or a malloc'd error string, and +sockets; on Apple platforms they have `SO_NOSIGPIPE` set. Keys and +addresses can be handled without a handle: `tailcat_key_generate`, +`tailcat_key_public`, `tailcat_key_addr`, `tailcat_addr_parse` and +`tailcat_addr_resolve` return `NULL` or a malloc'd error string, and their outputs are malloc'd too; `free()` all of them. diff --git a/libtailcat/include/tailcat.h b/libtailcat/include/tailcat.h index 8a527b5d1..4855770c0 100644 --- a/libtailcat/include/tailcat.h +++ b/libtailcat/include/tailcat.h @@ -5,8 +5,8 @@ // // tailcat is a control-plane-free network pipe built on Tailscale's data // plane (WireGuard encryption, NAT traversal, DERP relays as bootstrap and -// fallback). A server announces a connection token; clients holding the -// token can dial TCP ports on the server, which hands each accepted +// fallback). A server announces a tailcat address; clients holding the +// address can dial TCP ports on the server, which hands each accepted // connection to the caller as a file descriptor. // // Every connection given to C is one end of a socketpair(2) pumped by the @@ -19,7 +19,7 @@ // caller spawns does not inherit it and keep the connection alive. // // Every function is safe to call from any thread. Functions documented as -// blocking (server start, client ping, path, dial, token resolve) do +// blocking (server start, client ping, path, dial, address resolve) do // network work and must not be called on a UI thread. #include @@ -101,7 +101,7 @@ extern int tailcat_server_set_region_id(tailcat_handle sd, int region_id); // from the DERP map: hosts is a comma-separated list of DERP server // hostnames. Like the tailcat CLI, the server connects to the first host // listed. Because such a relay has no DERP map region ID to reference, the -// token always embeds the relay details. It overrides the region from the +// address always embeds the relay details. It overrides the region from the // key and any earlier tailcat_server_set_region_id call. // // Call before start. @@ -114,10 +114,10 @@ extern int tailcat_server_set_relay_hosts(tailcat_handle sd, const char* hosts); // Call before start. extern int tailcat_server_set_derpmap_url(tailcat_handle sd, const char* url); -// tailcat_server_set_embed_relay controls the token form. With embed set -// to 1, the token embeds the relay's details (hostname and addresses) so -// that clients need no DERP map fetch; it is longer, like the output of -// "tailcat serve --full-address". With 0 (the default) the token carries +// tailcat_server_set_embed_relay controls the address form. With embed set +// to 1, the address embeds the relay's details (hostname and IP addresses) +// so that clients need no DERP map fetch; it is longer, like the output of +// "tailcat serve --full-address". With 0 (the default) the address carries // just the DERP map region ID. Relay hosts set with // tailcat_server_set_relay_hosts are always embedded. // @@ -163,12 +163,12 @@ extern int tailcat_server_listen(tailcat_handle sd, int port, tailcat_listener* // Returns zero on success or -1 on error, call tailcat_errmsg for details. extern int tailcat_server_start(tailcat_handle sd); -// tailcat_server_token writes the server's connection token, the string +// tailcat_server_addr writes the server's tailcat address, the string // clients pass to tailcat_client_new (or to the tailcat CLI), to buf. // Valid after start. // // Returns 0, EBADF, ERANGE or -1 as described at the top of this file. -extern int tailcat_server_token(tailcat_handle sd, char* buf, size_t buflen); +extern int tailcat_server_addr(tailcat_handle sd, char* buf, size_t buflen); // tailcat_server_public_key writes the server's node public key // ("nodekey:") to buf. Valid any time after tailcat_server_new. @@ -222,13 +222,13 @@ extern int tailcat_conn_info(tailcat_listener l, tailcat_conn c, char* remote_bu // Client. -// tailcat_client_new creates a client for the server named by token. -// It returns 0 if the token is malformed (there is no handle to record an -// error on), otherwise a handle. Nothing happens on the network until the -// first ping, path or dial, which brings the client up: it resolves the -// server's relay (fetching the DERP map if the token doesn't embed it), -// connects to it and registers with the server. -extern tailcat_handle tailcat_client_new(const char* token); +// tailcat_client_new creates a client for the server named by addr, its +// tailcat address. It returns 0 if the address is malformed (there is no +// handle to record an error on), otherwise a handle. Nothing happens on +// the network until the first ping, path or dial, which brings the client +// up: it resolves the server's relay (fetching the DERP map if the address +// doesn't embed it), connects to it and registers with the server. +extern tailcat_handle tailcat_client_new(const char* addr); // tailcat_client_set_key sets the client's identity from key_json, the JSON // of a tailcat.PrivateKey (only its Private part is used), so a server can @@ -237,8 +237,9 @@ extern tailcat_handle tailcat_client_new(const char* token); // tailcat_client_public_key; afterwards it fails with -1. extern int tailcat_client_set_key(tailcat_handle cd, const char* key_json); -// tailcat_client_set_derpmap_url sets the DERP map URL used when the token -// doesn't embed the relay details. Call before the client's first use. +// tailcat_client_set_derpmap_url sets the DERP map URL used when the +// address doesn't embed the relay details. Call before the client's first +// use. extern int tailcat_client_set_derpmap_url(tailcat_handle cd, const char* url); // tailcat_client_public_key writes the client's node public key @@ -297,7 +298,7 @@ extern int tailcat_client_dial(tailcat_handle cd, int port, int timeout_ms, tail // -1 - other error, details go to the logger extern int tailcat_client_close(tailcat_handle cd); -// Keys and tokens. These take no handle. They return NULL on success or a +// Keys and addresses. These take no handle. They return NULL on success or a // malloc'd error string. All outputs are malloc'd and NUL-terminated; the // caller releases outputs and error strings with free(). @@ -311,25 +312,26 @@ extern char* tailcat_key_generate(char** key_json_out); // identity in key_json to *nodekey_out. extern char* tailcat_key_public(const char* key_json, char** nodekey_out); -// tailcat_key_token writes the token that a server using key_json will -// announce to *token_out. That is only known ahead of time when the key -// names a fixed DERP region or embeds relay hosts; with automatic region -// selection (RegionID -1) it fails, and the token must instead be read -// from the running server with tailcat_server_token. -extern char* tailcat_key_token(const char* key_json, char** token_out); - -// tailcat_token_parse decodes token and writes its fields as JSON to -// *json_out: ServerPublic, ServerDiscoPublic, and either RegionID or the -// embedded Region. It is the same output as "tailcat parse". -extern char* tailcat_token_parse(const char* token, char** json_out); - -// tailcat_token_resolve writes a self-contained form of token, with the -// relay's details embedded, to *token_out, the same as "tailcat resolve". -// A token that already embeds them is returned unchanged. The DERP map is -// fetched from derpmap_url, or the default map when derpmap_url is NULL -// or empty. It blocks for up to timeout_ms milliseconds (0 or less: no -// limit beyond the fetch's own). -extern char* tailcat_token_resolve(const char* token, const char* derpmap_url, int timeout_ms, char** token_out); +// tailcat_key_addr writes the tailcat address that a server using key_json +// will announce to *addr_out. That is only known ahead of time when the +// key names a fixed DERP region or embeds relay hosts; with automatic +// region selection (RegionID -1) it fails, and the address must instead be +// read from the running server with tailcat_server_addr. +extern char* tailcat_key_addr(const char* key_json, char** addr_out); + +// tailcat_addr_parse decodes the tailcat address addr and writes its +// fields as JSON to *json_out: ServerPublic, ServerDiscoPublic, and either +// RegionID or the embedded Region. It is the same output as "tailcat +// parse". +extern char* tailcat_addr_parse(const char* addr, char** json_out); + +// tailcat_addr_resolve writes a self-contained form of the tailcat address +// addr, with the relay's details embedded, to *addr_out, the same as +// "tailcat resolve". An address that already embeds them is returned +// unchanged. The DERP map is fetched from derpmap_url, or the default map +// when derpmap_url is NULL or empty. It blocks for up to timeout_ms +// milliseconds (0 or less: no limit beyond the fetch's own). +extern char* tailcat_addr_resolve(const char* addr, const char* derpmap_url, int timeout_ms, char** addr_out); #ifdef __cplusplus } diff --git a/libtailcat/libtailcat.go b/libtailcat/libtailcat.go index 9ea34ad98..113e82e5c 100644 --- a/libtailcat/libtailcat.go +++ b/libtailcat/libtailcat.go @@ -173,7 +173,7 @@ func cstrOut(buf *C.char, buflen C.size_t, s string) C.int { } // cerr returns err as a malloc'd C string, or NULL for nil, the -// convention of the handle-free key and token functions. +// convention of the handle-free key and address functions. func cerr(err error) *C.char { if err == nil { return nil @@ -259,14 +259,14 @@ type server struct { priv key.NodePrivate // zero until first needed ci tailcat.ConnInfo // where to listen; RegionID -1 means auto derpMapURL string // "" means tailcat.DefaultDERPMapURL - embed bool // token embeds the relay details + embed bool // the address embeds the relay details allowed []key.NodePublic // allowed clients, in order of addition logf logger.Logf // nil means log.Printf ports map[uint16]*listener // by port; 0 is the catch-all testRegion *tailcfg.DERPRegion // set by setServerRegionForTest starting bool // a start is in progress srv *tailcat.Server // non-nil once started - token tailcat.ConnBlob // valid once started + addr tailcat.Addr // the tailcat address; valid once started closed bool } @@ -489,7 +489,7 @@ func (s *server) start() error { priv := s.privLocked() ci := s.ci // A pre-populated region (relay hosts, from set_relay_hosts or the - // key) has no DERP map ID to reference, so its token always embeds + // key) has no DERP map ID to reference, so its address always embeds // the relay. Decide before Expand, which zeroes RegionID when it // populates Region. embed := s.embed || len(ci.Region) > 0 @@ -503,7 +503,7 @@ func (s *server) start() error { nAllowed := len(s.allowed) s.mu.Unlock() - srv, token, err := startServer(priv, ci, embed, url, logf, allowed, s.dispatch) + srv, addr, err := startServer(priv, ci, embed, url, logf, allowed, s.dispatch) s.mu.Lock() defer s.mu.Unlock() @@ -516,7 +516,7 @@ func (s *server) start() error { return errServerClosed } s.srv = srv - s.token = token + s.addr = addr // Keys allowed while the start was in progress. for _, k := range s.allowed[nAllowed:] { srv.AddAllowedClient(k) @@ -526,11 +526,11 @@ func (s *server) start() error { // startServer mirrors the tailcat CLI's server start sequence // (cmd/tailcat/tailcat.go, func server): expand ci into a DERP region, -// trim the region to what's needed, build the token, and start the -// server. The token is built here rather than with Server.ConnBlob, which -// always embeds the full region, so that the short form (a DERP map -// region ID) is available. -func startServer(priv key.NodePrivate, ci tailcat.ConnInfo, embed bool, derpMapURL string, logf logger.Logf, allowed []key.NodePublic, onTCP func(uint16) func(net.Conn)) (*tailcat.Server, tailcat.ConnBlob, error) { +// trim the region to what's needed, build the tailcat address, and start +// the server. The address is built here rather than with +// Server.TailcatAddr, which always embeds the full region, so that the +// short form (a DERP map region ID) is available. +func startServer(priv key.NodePrivate, ci tailcat.ConnInfo, embed bool, derpMapURL string, logf logger.Logf, allowed []key.NodePublic, onTCP func(uint16) func(net.Conn)) (*tailcat.Server, tailcat.Addr, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() opts := []any{tailcat.ExpandForServer} @@ -548,16 +548,16 @@ func startServer(priv key.NodePrivate, ci tailcat.ConnInfo, embed bool, derpMapU reg := ci.Region[0].Clone() clearUnnecessaryRegionFields(reg) - tok := tailcat.ConnInfo{ + info := tailcat.ConnInfo{ ServerPublic: tailcat.NodePublic{NodePublic: priv.Public()}, ServerDiscoPublic: tailcat.DiscoPublicForNode(priv), } if embed { - tok.Region = []*tailcfg.DERPRegion{reg} + info.Region = []*tailcfg.DERPRegion{reg} } else { - tok.RegionID = reg.RegionID + info.RegionID = reg.RegionID } - token := tok.ConnBlob() + addr := info.Addr() srv := &tailcat.Server{ Key: priv, @@ -569,11 +569,11 @@ func startServer(priv key.NodePrivate, ci tailcat.ConnInfo, embed bool, derpMapU if err := srv.Start(); err != nil { return nil, "", err } - return srv, token, nil + return srv, addr, nil } // clearUnnecessaryRegionFields is copied from the tailcat CLI: it drops -// the parts of a DERP map region that neither the server nor the token +// the parts of a DERP map region that neither the server nor the address // needs, keeping a single relay node so both sides use the same one. func clearUnnecessaryRegionFields(r *tailcfg.DERPRegion) { r.Latitude = 0 @@ -589,7 +589,7 @@ func clearUnnecessaryRegionFields(r *tailcfg.DERPRegion) { } // setServerRegionForTest makes the server at sd listen on reg without -// fetching a DERP map, embedding reg in its token, so tests can run +// fetching a DERP map, embedding reg in its address, so tests can run // against an in-process DERP server. Call it before start. func setServerRegionForTest(sd C.int, reg *tailcfg.DERPRegion) { _, s := getServer(sd) @@ -618,23 +618,23 @@ func (s *server) dispatch(port uint16) func(net.Conn) { return func(nc net.Conn) { ln.handoff(nc, port) } } -//export TailcatServerToken -func TailcatServerToken(sd C.int, buf *C.char, buflen C.size_t) C.int { - checkBuf("server_token", buf, buflen) +//export TailcatServerAddr +func TailcatServerAddr(sd C.int, buf *C.char, buflen C.size_t) C.int { + checkBuf("server_addr", buf, buflen) o, s := getServer(sd) if s == nil { *buf = '\x00' return C.EBADF } s.mu.Lock() - token := s.token + addr := s.addr started := s.srv != nil s.mu.Unlock() if !started { *buf = '\x00' return o.recErr(errNotStarted) } - return cstrOut(buf, buflen, string(token)) + return cstrOut(buf, buflen, string(addr)) } //export TailcatServerPublicKey @@ -1211,9 +1211,9 @@ func (c *client) use() (*tailcat.Client, error) { } //export TailcatClientNew -func TailcatClientNew(token *C.char) C.int { - blob := tailcat.ConnBlob(C.GoString(token)) - if _, err := tailcat.ParseConnBlob(blob); err != nil { +func TailcatClientNew(addr *C.char) C.int { + a := tailcat.Addr(C.GoString(addr)) + if _, err := tailcat.ParseAddr(a); err != nil { return 0 } o := &object{} @@ -1222,7 +1222,7 @@ func TailcatClientNew(token *C.char) C.int { // whose first use (a ping or dial on another thread) holds its start // lock across the DERP map fetch. tailcat adopts a Key that is set // before first use, so the tunnel uses exactly this key. - o.c = &client{obj: o, cl: &tailcat.Client{Server: blob, Key: key.NewNode()}} + o.c = &client{obj: o, cl: &tailcat.Client{Server: a, Key: key.NewNode()}} return newHandle(o) } @@ -1407,12 +1407,12 @@ func TailcatKeyPublic(keyJSON *C.char, nodekeyOut **C.char) *C.char { return nil } -//export TailcatKeyToken -func TailcatKeyToken(keyJSON *C.char, tokenOut **C.char) *C.char { - if tokenOut == nil { - panic("key_token passed nil token_out") +//export TailcatKeyAddr +func TailcatKeyAddr(keyJSON *C.char, addrOut **C.char) *C.char { + if addrOut == nil { + panic("key_addr passed nil addr_out") } - *tokenOut = nil + *addrOut = nil pk, err := parsePrivateKey(C.GoString(keyJSON)) if err != nil { return cerr(err) @@ -1420,7 +1420,7 @@ func TailcatKeyToken(keyJSON *C.char, tokenOut **C.char) *C.char { ci := pk.Public switch { case ci.RegionID == -1 && len(ci.Region) == 0: - return cerr(errors.New("the key's DERP region is auto (-1); the token is only known once the server starts")) + return cerr(errors.New("the key's DERP region is auto (-1); the address is only known once the server starts")) case ci.RegionID == 0 && len(ci.Region) == 0: return cerr(errors.New("the key has no DERP region")) } @@ -1428,17 +1428,17 @@ func TailcatKeyToken(keyJSON *C.char, tokenOut **C.char) *C.char { // exactly these. ci.ServerPublic = tailcat.NodePublic{NodePublic: pk.Private.Public()} ci.ServerDiscoPublic = tailcat.DiscoPublicForNode(pk.Private) - *tokenOut = C.CString(string(ci.ConnBlob())) + *addrOut = C.CString(string(ci.Addr())) return nil } -//export TailcatTokenParse -func TailcatTokenParse(token *C.char, jsonOut **C.char) *C.char { +//export TailcatAddrParse +func TailcatAddrParse(addr *C.char, jsonOut **C.char) *C.char { if jsonOut == nil { - panic("token_parse passed nil json_out") + panic("addr_parse passed nil json_out") } *jsonOut = nil - v, err := tailcat.ParseConnBlobRaw(tailcat.ConnBlob(C.GoString(token))) + v, err := tailcat.ParseAddrRaw(tailcat.Addr(C.GoString(addr))) if err != nil { return cerr(err) } @@ -1450,12 +1450,12 @@ func TailcatTokenParse(token *C.char, jsonOut **C.char) *C.char { return nil } -//export TailcatTokenResolve -func TailcatTokenResolve(token, derpMapURL *C.char, timeoutMs C.int, tokenOut **C.char) *C.char { - if tokenOut == nil { - panic("token_resolve passed nil token_out") +//export TailcatAddrResolve +func TailcatAddrResolve(addr, derpMapURL *C.char, timeoutMs C.int, addrOut **C.char) *C.char { + if addrOut == nil { + panic("addr_resolve passed nil addr_out") } - *tokenOut = nil + *addrOut = nil ctx, cancel := timeoutContext(timeoutMs) defer cancel() var opts []any @@ -1464,10 +1464,10 @@ func TailcatTokenResolve(token, derpMapURL *C.char, timeoutMs C.int, tokenOut ** opts = append(opts, tailcat.DERPMapURL(u)) } } - rb, err := tailcat.ConnBlob(C.GoString(token)).Resolve(ctx, opts...) + resolved, err := tailcat.Addr(C.GoString(addr)).Resolve(ctx, opts...) if err != nil { return cerr(err) } - *tokenOut = C.CString(string(rb)) + *addrOut = C.CString(string(resolved)) return nil } diff --git a/libtailcat/libtailcat_test.go b/libtailcat/libtailcat_test.go index 27210534a..1b2497366 100644 --- a/libtailcat/libtailcat_test.go +++ b/libtailcat/libtailcat_test.go @@ -24,11 +24,12 @@ import ( "tailscale.com/types/logger" ) -// The token from the README, and what "tailcat parse" shows for it. +// The tailcat address from the README, and what "tailcat parse" shows +// for it. const ( - readmeToken = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu" - readmeTokenKey = "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34" - readmeTokenRegn = 302 + readmeAddr = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu" + readmeAddrKey = "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34" + readmeAddrRegion = 302 ) func mkLogger(t testing.TB, name string) logger.Logf { @@ -263,23 +264,23 @@ func TestEndToEnd(t *testing.T) { if rc := TailcatServerListen(sd, 80, &lAny); rc != -1 || errmsg(sd) == "" { t.Fatalf("second listen on port 80: rc=%d, errmsg=%q; want -1 with a message", rc, errmsg(sd)) } - if rc := TailcatServerToken(sd, new(cChar), 1); rc != -1 { - t.Fatalf("token before start: rc=%d; want -1", rc) + if rc := TailcatServerAddr(sd, new(cChar), 1); rc != -1 { + t.Fatalf("addr before start: rc=%d; want -1", rc) } check(t, "start", sd, TailcatServerStart(sd)) if rc := TailcatServerStart(sd); rc != -1 { t.Fatalf("second start: rc=%d; want -1", rc) } check(t, "listen 0", sd, TailcatServerListen(sd, 0, &lAny)) - token := readBuf(t, "token", sd, func(b *cChar, n cSize) cInt { return TailcatServerToken(sd, b, n) }) + addr := readBuf(t, "addr", sd, func(b *cChar, n cSize) cInt { return TailcatServerAddr(sd, b, n) }) serverKey := readBuf(t, "public_key", sd, func(b *cChar, n cSize) cInt { return TailcatServerPublicKey(sd, b, n) }) - t.Logf("server token %s, key %s", token, serverKey) - if !strings.HasPrefix(token, "tc") || !strings.HasPrefix(serverKey, "nodekey:") { - t.Fatalf("malformed token %q or key %q", token, serverKey) + t.Logf("server address %s, key %s", addr, serverKey) + if !strings.HasPrefix(addr, "tc") || !strings.HasPrefix(serverKey, "nodekey:") { + t.Fatalf("malformed address %q or key %q", addr, serverKey) } var small [4]cChar - if rc := TailcatServerToken(sd, &small[0], cSize(len(small))); rc != cERANGE || small[3] != 0 || goString(&small[0]) != token[:3] { - t.Fatalf("token into a small buffer: rc=%d, got %q; want ERANGE and a NUL-terminated prefix", rc, goString(&small[0])) + if rc := TailcatServerAddr(sd, &small[0], cSize(len(small))); rc != cERANGE || small[3] != 0 || goString(&small[0]) != addr[:3] { + t.Fatalf("addr into a small buffer: rc=%d, got %q; want ERANGE and a NUL-terminated prefix", rc, goString(&small[0])) } var status *cChar check(t, "status_json", sd, TailcatServerStatusJSON(sd, &status)) @@ -288,11 +289,11 @@ func TestEndToEnd(t *testing.T) { } // Client. - ctoken := cString(token) - defer cFree(ctoken) - cd := TailcatClientNew(ctoken) + caddr := cString(addr) + defer cFree(caddr) + cd := TailcatClientNew(caddr) if cd == 0 { - t.Fatal("client_new returned 0 for the server's token") + t.Fatal("client_new returned 0 for the server's address") } clientLogFD, _ := logFD(t, "client") check(t, "client set_logfd", cd, TailcatSetLogFD(cd, clientLogFD)) @@ -344,9 +345,9 @@ func TestEndToEnd(t *testing.T) { // Now that the server is known to be reachable, a client that isn't // allowed must be ignored: its ping times out and the server logs // the rejection. - cd2 := TailcatClientNew(ctoken) + cd2 := TailcatClientNew(caddr) if cd2 == 0 { - t.Fatal("client_new returned 0 for the server's token") + t.Fatal("client_new returned 0 for the server's address") } check(t, "client2 set_logfd", cd2, TailcatSetLogFD(cd2, -1)) key2 := readBuf(t, "client2 public_key", cd2, func(b *cChar, n cSize) cInt { return TailcatClientPublicKey(cd2, b, n) }) @@ -431,8 +432,8 @@ func TestEndToEnd(t *testing.T) { t.Logf("dial to an unregistered port: rc=%d: %s", rc, errmsg(cd)) } - // Keys and tokens. - var keyJSON, nodekey, tok, parsed *cChar + // Keys and addresses. + var keyJSON, nodekey, keyAddr, parsed *cChar if e := TailcatKeyGenerate(&keyJSON); e != nil { t.Fatalf("key_generate: %s", gostr(e)) } @@ -451,33 +452,33 @@ func TestEndToEnd(t *testing.T) { if got, want := gostr(nodekey), pk.Private.Public().String(); got != want { t.Fatalf("key_public = %q; want %q", got, want) } - if e := TailcatKeyToken(ckj, &tok); e == nil || tok != nil { - t.Fatalf("key_token on an auto-region key succeeded: %q", gostr(tok)) + if e := TailcatKeyAddr(ckj, &keyAddr); e == nil || keyAddr != nil { + t.Fatalf("key_addr on an auto-region key succeeded: %q", gostr(keyAddr)) } else { - t.Logf("key_token on an auto-region key: %s", gostr(e)) + t.Logf("key_addr on an auto-region key: %s", gostr(e)) } cFree(ckj) - pk.Public.RegionID = readmeTokenRegn + pk.Public.RegionID = readmeAddrRegion fixed, err := json.Marshal(pk) if err != nil { t.Fatal(err) } cfixed := cString(string(fixed)) - if e := TailcatKeyToken(cfixed, &tok); e != nil { - t.Fatalf("key_token on a fixed-region key: %s", gostr(e)) + if e := TailcatKeyAddr(cfixed, &keyAddr); e != nil { + t.Fatalf("key_addr on a fixed-region key: %s", gostr(e)) } cFree(cfixed) - ci, err := tailcat.ParseConnBlob(tailcat.ConnBlob(gostr(tok))) + ci, err := tailcat.ParseAddr(tailcat.Addr(gostr(keyAddr))) if err != nil { - t.Fatalf("parsing key_token output: %v", err) + t.Fatalf("parsing key_addr output: %v", err) } - if ci.RegionID != readmeTokenRegn || ci.ServerPublic.NodePublic != pk.Private.Public() || ci.ServerDiscoPublic.IsZero() { - t.Fatalf("key_token output parsed to %+v", ci) + if ci.RegionID != readmeAddrRegion || ci.ServerPublic.NodePublic != pk.Private.Public() || ci.ServerDiscoPublic.IsZero() { + t.Fatalf("key_addr output parsed to %+v", ci) } - creadme := cString(readmeToken) - if e := TailcatTokenParse(creadme, &parsed); e != nil { - t.Fatalf("token_parse: %s", gostr(e)) + creadme := cString(readmeAddr) + if e := TailcatAddrParse(creadme, &parsed); e != nil { + t.Fatalf("addr_parse: %s", gostr(e)) } cFree(creadme) var fields struct { @@ -486,19 +487,19 @@ func TestEndToEnd(t *testing.T) { } pj := gostr(parsed) if err := json.Unmarshal([]byte(pj), &fields); err != nil { - t.Fatalf("token_parse JSON: %v\n%s", err, pj) + t.Fatalf("addr_parse JSON: %v\n%s", err, pj) } - if fields.ServerPublic != readmeTokenKey || fields.RegionID != readmeTokenRegn { - t.Fatalf("token_parse = %+v; want key %s and region %d", fields, readmeTokenKey, readmeTokenRegn) + if fields.ServerPublic != readmeAddrKey || fields.RegionID != readmeAddrRegion { + t.Fatalf("addr_parse = %+v; want key %s and region %d", fields, readmeAddrKey, readmeAddrRegion) } cbad := cString("nope") - if e := TailcatTokenParse(cbad, &parsed); e == nil { - t.Fatal("token_parse accepted a malformed token") + if e := TailcatAddrParse(cbad, &parsed); e == nil { + t.Fatal("addr_parse accepted a malformed address") } else { cFree(e) } if h := TailcatClientNew(cbad); h != 0 { - t.Fatalf("client_new on a malformed token = %d; want 0", h) + t.Fatalf("client_new on a malformed address = %d; want 0", h) } cFree(cbad) cempty := cString("{}") @@ -641,26 +642,26 @@ func TestClientPublicKeyDoesNotBlock(t *testing.T) { })) defer srv.Close() - // A token that references a DERP map region by ID, so the client - // must fetch the map, and carries a disco key (the README token + // An address that references a DERP map region by ID, so the client + // must fetch the map, and carries a disco key (the README address // predates those, and a client refuses to start without one). pk := tailcat.NewPrivateKey() - pk.Public.RegionID = readmeTokenRegn + pk.Public.RegionID = readmeAddrRegion js, err := json.Marshal(pk) if err != nil { t.Fatal(err) } cjs := cString(string(js)) - var tok *cChar - if e := TailcatKeyToken(cjs, &tok); e != nil { - t.Fatalf("key_token: %s", gostr(e)) + var keyAddr *cChar + if e := TailcatKeyAddr(cjs, &keyAddr); e != nil { + t.Fatalf("key_addr: %s", gostr(e)) } cFree(cjs) - ctoken := cString(gostr(tok)) - defer cFree(ctoken) - cd := TailcatClientNew(ctoken) + caddr := cString(gostr(keyAddr)) + defer cFree(caddr) + cd := TailcatClientNew(caddr) if cd == 0 { - t.Fatal("client_new returned 0 for a key_token token") + t.Fatal("client_new returned 0 for a key_addr address") } check(t, "set_logfd", cd, TailcatSetLogFD(cd, -1)) curl := cString(srv.URL + "/derpmap.json") diff --git a/libtailcat/tailcat.c b/libtailcat/tailcat.c index 7ed0e14be..92c45603b 100644 --- a/libtailcat/tailcat.c +++ b/libtailcat/tailcat.c @@ -22,7 +22,7 @@ extern int TailcatServerSetEmbedRelay(int sd, int embed); extern int TailcatServerAllowClient(int sd, char* nodekey); extern int TailcatServerListen(int sd, int port, int* listenerOut); extern int TailcatServerStart(int sd); -extern int TailcatServerToken(int sd, char* buf, size_t buflen); +extern int TailcatServerAddr(int sd, char* buf, size_t buflen); extern int TailcatServerPublicKey(int sd, char* buf, size_t buflen); extern int TailcatServerStatusJSON(int sd, char** jsonOut); extern int TailcatServerClose(int sd); @@ -30,7 +30,7 @@ extern int TailcatServerClose(int sd); extern int TailcatAccept(int l, int* connOut); extern int TailcatConnInfo(int l, int c, char* remoteBuf, size_t remoteBuflen, int* localPortOut); -extern int TailcatClientNew(char* token); +extern int TailcatClientNew(char* addr); extern int TailcatClientSetKey(int cd, char* keyJSON); extern int TailcatClientSetDERPMapURL(int cd, char* url); extern int TailcatClientPublicKey(int cd, char* buf, size_t buflen); @@ -41,9 +41,9 @@ extern int TailcatClientClose(int cd); extern char* TailcatKeyGenerate(char** keyJSONOut); extern char* TailcatKeyPublic(char* keyJSON, char** nodekeyOut); -extern char* TailcatKeyToken(char* keyJSON, char** tokenOut); -extern char* TailcatTokenParse(char* token, char** jsonOut); -extern char* TailcatTokenResolve(char* token, char* derpmapURL, int timeoutMs, char** tokenOut); +extern char* TailcatKeyAddr(char* keyJSON, char** addrOut); +extern char* TailcatAddrParse(char* addr, char** jsonOut); +extern char* TailcatAddrResolve(char* addr, char* derpmapURL, int timeoutMs, char** addrOut); int tailcat_errmsg(tailcat_handle h, char* buf, size_t buflen) { return TailcatErrmsg(h, buf, buflen); @@ -89,8 +89,8 @@ int tailcat_server_start(tailcat_handle sd) { return TailcatServerStart(sd); } -int tailcat_server_token(tailcat_handle sd, char* buf, size_t buflen) { - return TailcatServerToken(sd, buf, buflen); +int tailcat_server_addr(tailcat_handle sd, char* buf, size_t buflen) { + return TailcatServerAddr(sd, buf, buflen); } int tailcat_server_public_key(tailcat_handle sd, char* buf, size_t buflen) { @@ -113,8 +113,8 @@ int tailcat_conn_info(tailcat_listener l, tailcat_conn c, char* remote_buf, size return TailcatConnInfo(l, c, remote_buf, remote_buflen, local_port_out); } -tailcat_handle tailcat_client_new(const char* token) { - return TailcatClientNew((char*)token); +tailcat_handle tailcat_client_new(const char* addr) { + return TailcatClientNew((char*)addr); } int tailcat_client_set_key(tailcat_handle cd, const char* key_json) { @@ -153,14 +153,14 @@ char* tailcat_key_public(const char* key_json, char** nodekey_out) { return TailcatKeyPublic((char*)key_json, nodekey_out); } -char* tailcat_key_token(const char* key_json, char** token_out) { - return TailcatKeyToken((char*)key_json, token_out); +char* tailcat_key_addr(const char* key_json, char** addr_out) { + return TailcatKeyAddr((char*)key_json, addr_out); } -char* tailcat_token_parse(const char* token, char** json_out) { - return TailcatTokenParse((char*)token, json_out); +char* tailcat_addr_parse(const char* addr, char** json_out) { + return TailcatAddrParse((char*)addr, json_out); } -char* tailcat_token_resolve(const char* token, const char* derpmap_url, int timeout_ms, char** token_out) { - return TailcatTokenResolve((char*)token, (char*)derpmap_url, timeout_ms, token_out); +char* tailcat_addr_resolve(const char* addr, const char* derpmap_url, int timeout_ms, char** addr_out) { + return TailcatAddrResolve((char*)addr, (char*)derpmap_url, timeout_ms, addr_out); } diff --git a/swift/README.md b/swift/README.md index af344dd68..516da8ebf 100644 --- a/swift/README.md +++ b/swift/README.md @@ -2,8 +2,8 @@ A Swift package wrapping [libtailcat](../libtailcat/README.md), the C API over the [tailcat](../README.md) Go library, in async/await actors for -macOS 14+ and iOS 17+. A `TailcatServer` announces a connection token; -a `TailcatClient` holding the token dials TCP ports on it; both ends +macOS 14+ and iOS 17+. A `TailcatServer` announces a tailcat address; +a `TailcatClient` holding the address dials TCP ports on it; both ends handle bytes through `Connection`. Swift 6 language mode, strict concurrency, no Combine. @@ -37,8 +37,8 @@ import TailcatKit let server = try TailcatServer(configuration: .init(relay: .automatic), logger: DefaultLogger()) let listener = try await server.listen(on: 8080) // before or after start; 0 is the catch-all -let token = try await server.start() // blocks off-thread: DERP map, latency check -print("connect with: \(token)") +let address = try await server.start() // blocks off-thread: DERP map, latency check +print("connect with: \(address)") for try await connection in listener.connections { Task { @@ -54,7 +54,7 @@ for try await connection in listener.connections { A client: ```swift -let client = try TailcatClient(token: token) +let client = try TailcatClient(address: address) let rtt = try await client.ping() // brings the tunnel up; retry on .timeout right after the server started let path = try await client.path() // direct endpoint or relay region let connection = try await client.connect(port: 8080) @@ -65,14 +65,14 @@ connection.close() await client.close() ``` -Keys and tokens need no server or client: +Keys and addresses need no server or client: ```swift let identity = try Identity.generate() // a private key; keep it in the Keychain identity.publicKey // "nodekey:", for TailcatServer.allow -let token = ConnectionToken(rawValue: "tc...")! -let info = try token.parse() // server key, region ID or relay hosts -let long = try await token.resolved() // self-contained form, relay details embedded +let address = TailcatAddress(rawValue: "tc...")! +let info = try address.parse() // server key, region ID or relay hosts +let long = try await address.resolved() // self-contained form, relay details embedded ``` Restrict a server to known clients with `ServerConfiguration.allowedClients` @@ -81,12 +81,12 @@ public key is stable. The allow list gates registration: a client that registered while it was empty stays connected, so list the clients before start to lock a server down from the beginning. With a saved identity, `RelaySelection.automatic` -keeps the relay recorded in the key file (a fixed region keeps the token +keeps the relay recorded in the key file (a fixed region keeps the address stable across restarts); `.region(id)` and `.hosts([...])` override it. ### Notes -- Blocking C calls (server start, ping, path, connect, token resolve) +- Blocking C calls (server start, ping, path, connect, address resolve) run on a dedicated dispatch queue, never on an actor or the cooperative pool. Everything else is quick. - `Connection` is backed by DispatchIO: `receive` returns as soon as @@ -103,7 +103,7 @@ stable across restarts); `.region(id)` and `.hosts([...])` override it. `TailcatError.closed`. The Swift objects still own their descriptors until closed or deinitialized. - Errors are `TailcatError`; the Go side's message is carried in - `.internalError`, `.invalidToken` and `.invalidKey`. + `.internalError`, `.invalidAddress` and `.invalidKey`. ## The demo @@ -111,19 +111,19 @@ stable across restarts); `.region(id)` and `.hosts([...])` override it. against the Go CLI: ```sh -swift run tailcat-demo serve 7777 # prints the token on stderr, echoes bytes back uppercased -swift run tailcat-demo connect 7777 # pings, prints latency and path, pipes stdin, prints the reply -swift run tailcat-demo parse +swift run tailcat-demo serve 7777 # prints the address on stderr, echoes bytes back uppercased +swift run tailcat-demo connect
7777 # pings, prints latency and path, pipes stdin, prints the reply +swift run tailcat-demo parse
swift run tailcat-demo genkey ``` Against the Go CLI, in the repository root: ```sh -printf 'hi there\n' | go run ./cmd/tailcat 7777 # prints HI THERE +printf 'hi there\n' | go run ./cmd/tailcat
7777 # prints HI THERE go run ./cmd/tailcat serve 8080 # with a local server on 8080 -printf 'GET / HTTP/1.0\r\n\r\n' | swift run tailcat-demo connect 8080 +printf 'GET / HTTP/1.0\r\n\r\n' | swift run tailcat-demo connect
8080 ``` Set `TAILCAT_VERBOSE=1` to see the Go side's logs. diff --git a/swift/Sources/TailcatDemo/main.swift b/swift/Sources/TailcatDemo/main.swift index 9d5a471f0..80bff4c80 100644 --- a/swift/Sources/TailcatDemo/main.swift +++ b/swift/Sources/TailcatDemo/main.swift @@ -4,12 +4,12 @@ // tailcat-demo exercises TailcatKit from the command line and doubles as // the interop check against the Go tailcat CLI. // -// tailcat-demo serve start a server, print its token on stderr, -// echo every connection's bytes back uppercased -// tailcat-demo connect ping (print latency and path), connect, send -// stdin, print what comes back until EOF -// tailcat-demo parse print the token's contents -// tailcat-demo genkey print a new identity JSON and its public key +// tailcat-demo serve start a server, print its address on stderr, +// echo every connection's bytes back uppercased +// tailcat-demo connect
ping (print latency and path), connect, send +// stdin, print what comes back until EOF +// tailcat-demo parse
print the address's contents +// tailcat-demo genkey print a new identity JSON and its public key // // Set TAILCAT_VERBOSE=1 to see the Go side's logs on stderr. @@ -34,9 +34,9 @@ func parsePort(_ text: String) -> UInt16 { return port } -func parseToken(_ text: String) -> ConnectionToken { - guard let token = ConnectionToken(rawValue: text) else { fail("invalid token \(text)") } - return token +func parseAddress(_ text: String) -> TailcatAddress { + guard let address = TailcatAddress(rawValue: text) else { fail("invalid address \(text)") } + return address } func milliseconds(_ d: Duration) -> String { @@ -67,15 +67,15 @@ func serve(port: UInt16) async throws { let server = try TailcatServer(configuration: .init(), logger: makeLogger()) let listener = try await server.listen(on: port) note("# public key: \(server.publicKey)") - let token = try await server.start() - note("# Server listening on port \(port) with new address: \(token)") + let address = try await server.start() + note("# Server listening on port \(port) with new address: \(address)") for try await connection in listener.connections { Task { await echo(connection) } } } -func connect(token: ConnectionToken, port: UInt16) async throws { - let client = try TailcatClient(token: token, logger: makeLogger()) +func connect(address: TailcatAddress, port: UInt16) async throws { + let client = try TailcatClient(address: address, logger: makeLogger()) // A server that just started may still be connecting to its relay, // in which case the first probe times out; try a few times. var latency: Duration? @@ -119,8 +119,8 @@ func connect(token: ConnectionToken, port: UInt16) async throws { await client.close() } -func parse(token: ConnectionToken) throws { - let info = try token.parse() +func parse(address: TailcatAddress) throws { + let info = try address.parse() print("server public key: \(info.serverPublicKey)") if let region = info.regionID { print("DERP region ID: \(region)") @@ -143,13 +143,13 @@ do { case "serve" where args.count == 2: try await serve(port: parsePort(args[1])) case "connect" where args.count == 3: - try await connect(token: parseToken(args[1]), port: parsePort(args[2])) + try await connect(address: parseAddress(args[1]), port: parsePort(args[2])) case "parse" where args.count == 2: - try parse(token: parseToken(args[1])) + try parse(address: parseAddress(args[1])) case "genkey" where args.count == 1: try genkey() default: - note("usage: tailcat-demo serve | connect | parse | genkey") + note("usage: tailcat-demo serve | connect
| parse
| genkey") exit(2) } } catch { diff --git a/swift/Sources/TailcatKit/Blocking.swift b/swift/Sources/TailcatKit/Blocking.swift index dc002b13a..7af4cf3b7 100644 --- a/swift/Sources/TailcatKit/Blocking.swift +++ b/swift/Sources/TailcatKit/Blocking.swift @@ -6,7 +6,7 @@ import Dispatch /// Runs blocking C calls off the Swift concurrency thread pool. /// /// tailcat_server_start, tailcat_client_ping, tailcat_client_path_json, -/// tailcat_client_dial and tailcat_token_resolve block for the duration +/// tailcat_client_dial and tailcat_addr_resolve block for the duration /// of their network work, so they run on a dedicated dispatch queue and /// callers await a continuation. They are never called on an actor /// executor or on the cooperative pool. diff --git a/swift/Sources/TailcatKit/Identity.swift b/swift/Sources/TailcatKit/Identity.swift index 7560feb58..906614567 100644 --- a/swift/Sources/TailcatKit/Identity.swift +++ b/swift/Sources/TailcatKit/Identity.swift @@ -49,14 +49,14 @@ public struct Identity: Sendable, Codable, Hashable { /// init. public var publicKey: NodePublicKey { cachedPublicKey } - /// The token a server using this identity will announce - /// (tailcat_key_token). It is only known ahead of time when the key + /// The tailcat address a server using this identity will announce + /// (tailcat_key_addr). It is only known ahead of time when the key /// names a fixed DERP region or embeds relay hosts; with automatic - /// selection it throws TailcatError.relayNotFixed, and the token must + /// selection it throws TailcatError.relayNotFixed, and the address must /// be read from a started TailcatServer instead. - public func token() throws -> ConnectionToken { + public func address() throws -> TailcatAddress { var out: UnsafeMutablePointer? = nil - let err = json.withCString { tailcat_key_token($0, &out) } + let err = json.withCString { tailcat_key_addr($0, &out) } if let message = CStrings.take(err) { free(out) if message.lowercased().contains("region") { @@ -64,10 +64,10 @@ public struct Identity: Sendable, Codable, Hashable { } throw TailcatError.invalidKey(message) } - guard let text = CStrings.take(out), let token = ConnectionToken(rawValue: text) else { - throw TailcatError.internalError("tailcat_key_token returned an unexpected token") + guard let text = CStrings.take(out), let address = TailcatAddress(rawValue: text) else { + throw TailcatError.internalError("tailcat_key_addr returned an unexpected address") } - return token + return address } /// Decodes the key JSON (as a single string), validating it. diff --git a/swift/Sources/TailcatKit/Relay.swift b/swift/Sources/TailcatKit/Relay.swift index d86b72023..3300e2548 100644 --- a/swift/Sources/TailcatKit/Relay.swift +++ b/swift/Sources/TailcatKit/Relay.swift @@ -14,13 +14,13 @@ public enum RelaySelection: Sendable, Hashable { /// ignoring the identity. case region(Int) /// Your own DERP relays, by hostname (tailcat_server_set_relay_hosts). - /// The server connects to the first; the token always embeds them. + /// The server connects to the first; the address always embeds them. case hosts([String]) } /// What a TailcatServer is built from. public struct ServerConfiguration: Sendable { - /// The server's identity; nil generates an ephemeral one whose token + /// The server's identity; nil generates an ephemeral one whose address /// nobody has seen before. public var identity: Identity? = nil /// The relay to use. @@ -28,17 +28,17 @@ public struct ServerConfiguration: Sendable { /// The DERP map to resolve regions against; nil uses tailcat's default /// map (https://tailcat.dev/derpmap.json). public var derpMapURL: URL? = nil - /// Whether the token embeds the relay's details (self-contained but + /// Whether the address embeds the relay's details (self-contained but /// longer, like "tailcat serve --full-address") instead of a region /// ID. Relay hosts are always embedded. - public var embedRelayInToken: Bool = false + public var embedRelayInAddress: Bool = false /// Clients allowed to connect, by public key. Empty allows everyone; /// once any key is listed (or allowed later), only listed keys can /// connect. public var allowedClients: [NodePublicKey] = [] /// The defaults: an ephemeral identity, automatic relay selection, the - /// default DERP map, a short token, every client allowed. + /// default DERP map, a short address, every client allowed. public init() {} /// A configuration with the given settings; unspecified ones keep @@ -47,13 +47,13 @@ public struct ServerConfiguration: Sendable { identity: Identity? = nil, relay: RelaySelection = .automatic, derpMapURL: URL? = nil, - embedRelayInToken: Bool = false, + embedRelayInAddress: Bool = false, allowedClients: [NodePublicKey] = [] ) { self.identity = identity self.relay = relay self.derpMapURL = derpMapURL - self.embedRelayInToken = embedRelayInToken + self.embedRelayInAddress = embedRelayInAddress self.allowedClients = allowedClients } } diff --git a/swift/Sources/TailcatKit/ConnectionToken.swift b/swift/Sources/TailcatKit/TailcatAddress.swift similarity index 65% rename from swift/Sources/TailcatKit/ConnectionToken.swift rename to swift/Sources/TailcatKit/TailcatAddress.swift index d3ebaa8f3..1d9f96282 100644 --- a/swift/Sources/TailcatKit/ConnectionToken.swift +++ b/swift/Sources/TailcatKit/TailcatAddress.swift @@ -38,121 +38,121 @@ public struct NodePublicKey: Sendable, Hashable, Codable, RawRepresentable, Cust } } -/// A server's connection token (the "tc..." string a tailcat server +/// A server's tailcat address (the "tc..." string a tailcat server /// announces), which names the server's keys and its relay. -public struct ConnectionToken: Sendable, Hashable, Codable, RawRepresentable, CustomStringConvertible { - /// The token text. +public struct TailcatAddress: Sendable, Hashable, Codable, RawRepresentable, CustomStringConvertible { + /// The address text. public let rawValue: String - /// Creates a token from its text. Only the "tc" prefix is checked + /// Creates an address from its text. Only the "tc" prefix is checked /// here; parse() validates the rest. public init?(rawValue: String) { guard rawValue.hasPrefix("tc"), rawValue.count > 2 else { return nil } self.rawValue = rawValue } - /// The token text. + /// The address text. public var description: String { rawValue } - /// Decodes the token text, checking its prefix. + /// Decodes the address text, checking its prefix. public init(from decoder: any Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) - guard let token = ConnectionToken(rawValue: raw) else { - throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "not a tc... token")) + guard let address = TailcatAddress(rawValue: raw) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "not a tc... address")) } - self = token + self = address } - /// Encodes the token text. + /// Encodes the address text. public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(rawValue) } - /// Decodes the token without touching the network (tailcat_token_parse, - /// the same as "tailcat parse"). Throws TailcatError.invalidToken for a - /// malformed token. - public func parse() throws -> TokenInfo { + /// Decodes the address without touching the network (tailcat_addr_parse, + /// the same as "tailcat parse"). Throws TailcatError.invalidAddress for a + /// malformed address. + public func parse() throws -> AddressInfo { var out: UnsafeMutablePointer? = nil - let err = rawValue.withCString { tailcat_token_parse($0, &out) } + let err = rawValue.withCString { tailcat_addr_parse($0, &out) } if let message = CStrings.take(err) { free(out) - throw TailcatError.invalidToken(message) + throw TailcatError.invalidAddress(message) } guard let json = CStrings.takeData(out) else { - throw TailcatError.internalError("tailcat_token_parse returned no JSON") + throw TailcatError.internalError("tailcat_addr_parse returned no JSON") } - return try TokenInfo(json: json) + return try AddressInfo(json: json) } - /// Returns the self-contained form of the token, with the relay's + /// Returns the self-contained form of the address, with the relay's /// details embedded so clients need no DERP map fetch (the same as - /// "tailcat resolve"). A token that already embeds them comes back + /// "tailcat resolve"). An address that already embeds them comes back /// unchanged. The DERP map is fetched from derpMapURL, or the default /// map when nil. The work runs off the Swift concurrency threads. The /// timeout is rounded up to whole milliseconds; zero means no limit - /// beyond the fetch's own. Throws TailcatError.invalidToken for a - /// malformed token, TailcatError.timeout when time runs out, and + /// beyond the fetch's own. Throws TailcatError.invalidAddress for a + /// malformed address, TailcatError.timeout when time runs out, and /// otherwise the fetch's error, such as /// TailcatError.posix(ECONNREFUSED, _) or TailcatError.internalError. - public func resolved(derpMapURL: URL? = nil, timeout: Duration = .seconds(10)) async throws -> ConnectionToken { - let token = rawValue + public func resolved(derpMapURL: URL? = nil, timeout: Duration = .seconds(10)) async throws -> TailcatAddress { + let address = rawValue let url = derpMapURL?.absoluteString let ms = timeout.millisecondsForC let resolved: String = try await Blocking.run { var out: UnsafeMutablePointer? = nil - let err = token.withCString { t -> UnsafeMutablePointer? in + let err = address.withCString { a -> UnsafeMutablePointer? in if let url { - return url.withCString { tailcat_token_resolve(t, $0, ms, &out) } + return url.withCString { tailcat_addr_resolve(a, $0, ms, &out) } } - return tailcat_token_resolve(t, nil, ms, &out) + return tailcat_addr_resolve(a, nil, ms, &out) } if let message = CStrings.take(err) { free(out) - // Resolving fails either because the token is malformed, + // Resolving fails either because the address is malformed, // which parse() detects offline, or because the DERP map // could not be fetched, which says nothing about the - // token. + // address. if (try? self.parse()) == nil { - throw TailcatError.invalidToken(message) + throw TailcatError.invalidAddress(message) } throw TailcatError.classify(message: message) } guard let text = CStrings.take(out) else { - throw TailcatError.internalError("tailcat_token_resolve returned no token") + throw TailcatError.internalError("tailcat_addr_resolve returned no address") } return text } - guard let result = ConnectionToken(rawValue: resolved) else { - throw TailcatError.internalError("tailcat_token_resolve returned an unexpected token") + guard let result = TailcatAddress(rawValue: resolved) else { + throw TailcatError.internalError("tailcat_addr_resolve returned an unexpected address") } return result } } -/// The contents of a connection token. -public struct TokenInfo: Sendable, Hashable { +/// The contents of a tailcat address. +public struct AddressInfo: Sendable, Hashable { /// The server's node public key. public let serverPublicKey: NodePublicKey - /// The DERP map region the server uses, when the token references one + /// The DERP map region the server uses, when the address references one /// by ID; nil when it embeds the relay details instead. public let regionID: Int? - /// The hostnames of the relays embedded in the token, in order; empty - /// when the token references a region by ID. + /// The hostnames of the relays embedded in the address, in order; empty + /// when the address references a region by ID. public let relayHosts: [String] - /// The full decoded token as JSON, the output of "tailcat parse". + /// The full decoded address as JSON, the output of "tailcat parse". public let json: Data - /// Decodes the JSON of tailcat_token_parse. + /// Decodes the JSON of tailcat_addr_parse. init(json: Data) throws { - let raw: RawToken + let raw: RawAddress do { - raw = try JSONDecoder().decode(RawToken.self, from: json) + raw = try JSONDecoder().decode(RawAddress.self, from: json) } catch { - throw TailcatError.internalError("decoding token JSON: \(error)") + throw TailcatError.internalError("decoding address JSON: \(error)") } guard let key = NodePublicKey(rawValue: raw.serverPublic) else { - throw TailcatError.invalidToken("unexpected server public key \(raw.serverPublic)") + throw TailcatError.invalidAddress("unexpected server public key \(raw.serverPublic)") } serverPublicKey = key let hosts = (raw.region ?? []).flatMap { $0.nodes ?? [] }.compactMap { $0.hostName }.filter { !$0.isEmpty } @@ -165,7 +165,7 @@ public struct TokenInfo: Sendable, Hashable { self.json = json } - private struct RawToken: Decodable { + private struct RawAddress: Decodable { var serverPublic: String var regionID: Int? var region: [RawRegion]? diff --git a/swift/Sources/TailcatKit/TailcatClient.swift b/swift/Sources/TailcatKit/TailcatClient.swift index 50c77f652..9b07ff00c 100644 --- a/swift/Sources/TailcatKit/TailcatClient.swift +++ b/swift/Sources/TailcatKit/TailcatClient.swift @@ -4,7 +4,7 @@ import CTailcat import Foundation -/// A tailcat client: given a server's connection token, it pings the +/// A tailcat client: given a server's tailcat address, it pings the /// server and dials TCP ports on it. /// /// Nothing happens on the network until the first ping, path or connect, @@ -12,27 +12,27 @@ import Foundation /// registering with the server). Those calls run off the Swift /// concurrency threads. public actor TailcatClient { - /// The server's connection token. - public nonisolated let token: ConnectionToken + /// The server's tailcat address. + public nonisolated let address: TailcatAddress private var handle: Int32 private let logger: any LogSink - /// Creates a client for the server named by token (tailcat_client_new), + /// Creates a client for the server named by address (tailcat_client_new), /// with an optional identity (so the server can allow it by public - /// key; ephemeral otherwise) and DERP map URL (used when the token - /// references a region by ID). Throws TailcatError.invalidToken for a - /// malformed token. - public init(token: ConnectionToken, identity: Identity? = nil, derpMapURL: URL? = nil, logger: any LogSink = BlackholeLogger()) throws { - let h = token.rawValue.withCString { tailcat_client_new($0) } + /// key; ephemeral otherwise) and DERP map URL (used when the address + /// references a region by ID). Throws TailcatError.invalidAddress for a + /// malformed address. + public init(address: TailcatAddress, identity: Identity? = nil, derpMapURL: URL? = nil, logger: any LogSink = BlackholeLogger()) throws { + let h = address.rawValue.withCString { tailcat_client_new($0) } guard h != 0 else { - var message = "malformed token" + var message = "malformed address" do { - _ = try token.parse() - } catch TailcatError.invalidToken(let text) { + _ = try address.parse() + } catch TailcatError.invalidAddress(let text) { message = text } catch {} - throw TailcatError.invalidToken(message) + throw TailcatError.invalidAddress(message) } do { try TailcatError.check(tailcat_set_logfd(h, logger.logFileDescriptor ?? -1), handle: h) @@ -46,7 +46,7 @@ public actor TailcatClient { _ = tailcat_client_close(h) throw error } - self.token = token + self.address = address self.handle = h self.logger = logger } diff --git a/swift/Sources/TailcatKit/TailcatError.swift b/swift/Sources/TailcatKit/TailcatError.swift index 5b715c7c9..0ef1c21cd 100644 --- a/swift/Sources/TailcatKit/TailcatError.swift +++ b/swift/Sources/TailcatKit/TailcatError.swift @@ -9,13 +9,13 @@ public enum TailcatError: Error, Sendable, Equatable, CustomStringConvertible { /// The underlying C handle is not valid, typically because the object /// was closed. case invalidHandle - /// The connection token is malformed; the payload is the parser's + /// The tailcat address is malformed; the payload is the parser's /// message. - case invalidToken(String) + case invalidAddress(String) /// The identity JSON is malformed; the payload is the parser's message. case invalidKey(String) /// The identity leaves the relay to be picked when a server starts, so - /// its token is only known once such a server is running. + /// its address is only known once such a server is running. case relayNotFixed /// The operation needs a started server. case notStarted @@ -36,9 +36,9 @@ public enum TailcatError: Error, Sendable, Equatable, CustomStringConvertible { public var description: String { switch self { case .invalidHandle: "invalid handle" - case .invalidToken(let message): "invalid connection token: \(message)" + case .invalidAddress(let message): "invalid tailcat address: \(message)" case .invalidKey(let message): "invalid identity: \(message)" - case .relayNotFixed: "the identity's relay is chosen at start; the token is only known once a server using it has started" + case .relayNotFixed: "the identity's relay is chosen at start; the address is only known once a server using it has started" case .notStarted: "the server is not started" case .alreadyStarted: "the server is already started" case .closed: "closed" diff --git a/swift/Sources/TailcatKit/TailcatServer.swift b/swift/Sources/TailcatKit/TailcatServer.swift index db12e1136..b1e45760d 100644 --- a/swift/Sources/TailcatKit/TailcatServer.swift +++ b/swift/Sources/TailcatKit/TailcatServer.swift @@ -4,18 +4,18 @@ import CTailcat import Foundation -/// A tailcat server: it announces a connection token, and clients holding -/// the token dial TCP ports on it, which Listeners accept. +/// A tailcat server: it announces a tailcat address, and clients holding +/// the address dial TCP ports on it, which Listeners accept. /// /// Create it, register listeners, then start() (which does the network -/// work off the Swift concurrency threads) and share the token. Ports may +/// work off the Swift concurrency threads) and share the address. Ports may /// also be registered after start. public actor TailcatServer { /// The server's node public key, "nodekey:", known before start. public nonisolated let publicKey: NodePublicKey - /// The connection token, once start() has returned it. - public private(set) var token: ConnectionToken? + /// The tailcat address, once start() has returned it. + public private(set) var address: TailcatAddress? private var handle: Int32 private var state: State = .idle @@ -56,7 +56,7 @@ public actor TailcatServer { if let url = configuration.derpMapURL { try TailcatError.check(url.absoluteString.withCString { tailcat_server_set_derpmap_url(h, $0) }, handle: h) } - if configuration.embedRelayInToken { + if configuration.embedRelayInAddress { try TailcatError.check(tailcat_server_set_embed_relay(h, 1), handle: h) } for client in configuration.allowedClients { @@ -101,12 +101,12 @@ public actor TailcatServer { /// Starts the server (tailcat_server_start, off-thread): resolves the /// relay, fetching the DERP map and measuring latencies as needed, and - /// returns the connection token, also kept in `token`. Throws + /// returns the tailcat address, also kept in `address`. Throws /// TailcatError.alreadyStarted on a second call. Like the tailcat CLI /// it returns once the server is configured; the relay connection /// completes in the background right after, so a client pinging /// within the first seconds may time out and should retry. - public func start() async throws -> ConnectionToken { + public func start() async throws -> TailcatAddress { switch state { case .closed: throw TailcatError.closed @@ -134,14 +134,14 @@ public actor TailcatServer { throw TailcatError.closed } var buf = [CChar](repeating: 0, count: 4096) - try TailcatError.check(buf.withUnsafeMutableBufferPointer { tailcat_server_token(h, $0.baseAddress, $0.count) }, handle: h) - guard let token = ConnectionToken(rawValue: CStrings.string(buf)) else { - throw TailcatError.internalError("unexpected token format") + try TailcatError.check(buf.withUnsafeMutableBufferPointer { tailcat_server_addr(h, $0.baseAddress, $0.count) }, handle: h) + guard let address = TailcatAddress(rawValue: CStrings.string(buf)) else { + throw TailcatError.internalError("unexpected address format") } - self.token = token + self.address = address state = .running - logger.log("TailcatServer: started, token \(token)") - return token + logger.log("TailcatServer: started, address \(address)") + return address } /// Allows a client by public key, before or after start. The allow diff --git a/swift/Tests/TailcatKitTests/AddressTests.swift b/swift/Tests/TailcatKitTests/AddressTests.swift new file mode 100644 index 000000000..ee88d8fb0 --- /dev/null +++ b/swift/Tests/TailcatKitTests/AddressTests.swift @@ -0,0 +1,114 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +import Foundation +import TailcatKit +import XCTest + +/// Offline tests of TailcatAddress, AddressInfo and NodePublicKey. +final class AddressTests: XCTestCase { + /// The address in the README, referencing DERP region 302. + static let readmeAddress = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu" + /// The same server's resolved address, with the relay embedded. + static let resolvedAddress = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA" + static let readmeKey = "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34" + + func testParseReadmeAddress() throws { + let address = try XCTUnwrap(TailcatAddress(rawValue: Self.readmeAddress)) + XCTAssertEqual(address.rawValue, Self.readmeAddress) + XCTAssertEqual(address.description, Self.readmeAddress) + let info = try address.parse() + XCTAssertEqual(info.serverPublicKey.rawValue, Self.readmeKey) + XCTAssertEqual(info.regionID, 302) + XCTAssertEqual(info.relayHosts, []) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: info.json) as? [String: Any]) + XCTAssertEqual(json["RegionID"] as? Int, 302) + XCTAssertEqual(json["ServerPublic"] as? String, Self.readmeKey) + } + + func testParseResolvedAddress() throws { + let address = try XCTUnwrap(TailcatAddress(rawValue: Self.resolvedAddress)) + let info = try address.parse() + XCTAssertEqual(info.serverPublicKey.rawValue, Self.readmeKey) + XCTAssertNil(info.regionID) + XCTAssertEqual(info.relayHosts, ["tc302a.ipn.dev"]) + } + + func testInvalidPrefixIsNil() { + XCTAssertNil(TailcatAddress(rawValue: "nope")) + XCTAssertNil(TailcatAddress(rawValue: "")) + XCTAssertNil(TailcatAddress(rawValue: "tc")) + XCTAssertNotNil(TailcatAddress(rawValue: "tcx")) + } + + func testGarbageThrowsInvalidAddress() throws { + let address = try XCTUnwrap(TailcatAddress(rawValue: "tcgarbage")) + XCTAssertThrowsError(try address.parse()) { error in + guard case TailcatError.invalidAddress(let message) = error else { + return XCTFail("unexpected error \(error)") + } + XCTAssertFalse(message.isEmpty) + } + } + + func testAddressCodable() throws { + let address = try XCTUnwrap(TailcatAddress(rawValue: Self.readmeAddress)) + let encoded = try JSONEncoder().encode([address]) + XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "[\"\(Self.readmeAddress)\"]") + let decoded = try JSONDecoder().decode([TailcatAddress].self, from: encoded) + XCTAssertEqual(decoded, [address]) + XCTAssertThrowsError(try JSONDecoder().decode(TailcatAddress.self, from: Data("\"nope\"".utf8))) + } + + func testNodePublicKeyValidation() throws { + let key = try XCTUnwrap(NodePublicKey(rawValue: Self.readmeKey)) + XCTAssertEqual(key.rawValue, Self.readmeKey) + XCTAssertEqual(key.description, Self.readmeKey) + XCTAssertNil(NodePublicKey(rawValue: "9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34")) + XCTAssertNil(NodePublicKey(rawValue: "nodekey:9c8d2e67")) + XCTAssertNil(NodePublicKey(rawValue: "nodekey:" + String(repeating: "g", count: 64))) + XCTAssertNotNil(NodePublicKey(rawValue: "nodekey:" + String(repeating: "A", count: 64))) + let encoded = try JSONEncoder().encode([key]) + XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "[\"\(Self.readmeKey)\"]") + XCTAssertEqual(try JSONDecoder().decode([NodePublicKey].self, from: encoded), [key]) + } + + /// A DERP map that cannot be fetched is a network failure, not a bad + /// address: only a malformed address is reported as invalidAddress. + func testResolveMapsNetworkFailures() async throws { + // Nothing listens on port 9 of the loopback interface, so the + // fetch is refused outright. + let refused = try XCTUnwrap(URL(string: "http://127.0.0.1:9/derpmap.json")) + let address = try XCTUnwrap(TailcatAddress(rawValue: Self.readmeAddress)) + do { + _ = try await address.resolved(derpMapURL: refused, timeout: .seconds(5)) + XCTFail("resolving against a refused DERP map URL succeeded") + } catch TailcatError.invalidAddress(let message) { + XCTFail("a network failure was reported as an invalid address: \(message)") + } catch let error as TailcatError { + if case .posix(let code, _) = error { + XCTAssertEqual(code, ECONNREFUSED) + } + } + // A malformed address is invalid whatever the map. + let garbage = try XCTUnwrap(TailcatAddress(rawValue: "tcgarbage")) + do { + _ = try await garbage.resolved(derpMapURL: refused, timeout: .seconds(5)) + XCTFail("resolving a malformed address succeeded") + } catch TailcatError.invalidAddress { + } + // An address that embeds its relay resolves to itself, offline. + let embedded = try XCTUnwrap(TailcatAddress(rawValue: Self.resolvedAddress)) + let same = try await embedded.resolved(derpMapURL: refused, timeout: .seconds(5)) + XCTAssertEqual(same, embedded) + } + + func testClientRejectsMalformedAddress() throws { + let address = try XCTUnwrap(TailcatAddress(rawValue: "tcgarbage")) + XCTAssertThrowsError(try TailcatClient(address: address)) { error in + guard case TailcatError.invalidAddress = error else { + return XCTFail("unexpected error \(error)") + } + } + } +} diff --git a/swift/Tests/TailcatKitTests/EndToEndTests.swift b/swift/Tests/TailcatKitTests/EndToEndTests.swift index 331774dfa..2745a1309 100644 --- a/swift/Tests/TailcatKitTests/EndToEndTests.swift +++ b/swift/Tests/TailcatKitTests/EndToEndTests.swift @@ -30,11 +30,11 @@ final class EndToEndTests: XCTestCase { static func roundTrip() async throws { let server = try TailcatServer(configuration: .init(relay: .automatic), logger: BlackholeLogger()) let listener = try await server.listen(on: 7000) - let token = try await server.start() - XCTAssertTrue(token.rawValue.hasPrefix("tc")) - let kept = await server.token - XCTAssertEqual(kept, token) - let info = try token.parse() + let address = try await server.start() + XCTAssertTrue(address.rawValue.hasPrefix("tc")) + let kept = await server.address + XCTAssertEqual(kept, address) + let info = try address.parse() XCTAssertEqual(info.serverPublicKey, server.publicKey) XCTAssertNotNil(info.regionID) @@ -57,7 +57,7 @@ final class EndToEndTests: XCTestCase { return eof.isEmpty } - let client = try TailcatClient(token: token, logger: BlackholeLogger()) + let client = try TailcatClient(address: address, logger: BlackholeLogger()) // The relay connection completes shortly after start() returns, // so the first probe may time out. var latency: Duration? diff --git a/swift/Tests/TailcatKitTests/IdentityTests.swift b/swift/Tests/TailcatKitTests/IdentityTests.swift index 9bf44dd66..9a00c2eaf 100644 --- a/swift/Tests/TailcatKitTests/IdentityTests.swift +++ b/swift/Tests/TailcatKitTests/IdentityTests.swift @@ -19,9 +19,9 @@ final class IdentityTests: XCTestCase { XCTAssertNotEqual(try Identity.generate().publicKey, identity.publicKey) } - func testTokenNeedsFixedRelay() throws { + func testAddressNeedsFixedRelay() throws { let identity = try Identity.generate() - XCTAssertThrowsError(try identity.token()) { error in + XCTAssertThrowsError(try identity.address()) { error in XCTAssertEqual(error as? TailcatError, .relayNotFixed) } } @@ -46,8 +46,8 @@ final class IdentityTests: XCTestCase { XCTAssertThrowsError(try JSONDecoder().decode(Identity.self, from: Data("\"{}\"".utf8))) } - func testFixedRegionToken() throws { - // Pin the generated key to region 302 and check the token it + func testFixedRegionAddress() throws { + // Pin the generated key to region 302 and check the address it // yields names that region and this key. let identity = try Identity.generate() var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(identity.json.utf8)) as? [String: Any]) @@ -56,9 +56,9 @@ final class IdentityTests: XCTestCase { json["Public"] = pub let pinned = try Identity(json: String(decoding: try JSONSerialization.data(withJSONObject: json), as: UTF8.self)) XCTAssertEqual(pinned.publicKey, identity.publicKey) - let token = try pinned.token() - XCTAssertTrue(token.rawValue.hasPrefix("tc")) - let info = try token.parse() + let address = try pinned.address() + XCTAssertTrue(address.rawValue.hasPrefix("tc")) + let info = try address.parse() XCTAssertEqual(info.serverPublicKey, identity.publicKey) XCTAssertEqual(info.regionID, 302) XCTAssertEqual(info.relayHosts, []) diff --git a/swift/Tests/TailcatKitTests/OfflineTests.swift b/swift/Tests/TailcatKitTests/OfflineTests.swift index 4bec847c0..7a3ab6bfa 100644 --- a/swift/Tests/TailcatKitTests/OfflineTests.swift +++ b/swift/Tests/TailcatKitTests/OfflineTests.swift @@ -257,19 +257,19 @@ final class ServerLifecycleTests: XCTestCase { XCTAssertNil(config.identity) XCTAssertEqual(config.relay, .automatic) XCTAssertNil(config.derpMapURL) - XCTAssertFalse(config.embedRelayInToken) + XCTAssertFalse(config.embedRelayInAddress) XCTAssertEqual(config.allowedClients, []) - let custom = ServerConfiguration(relay: .region(302), embedRelayInToken: true) + let custom = ServerConfiguration(relay: .region(302), embedRelayInAddress: true) XCTAssertEqual(custom.relay, .region(302)) - XCTAssertTrue(custom.embedRelayInToken) + XCTAssertTrue(custom.embedRelayInAddress) } func testServerBeforeStart() async throws { let identity = try Identity.generate() let server = try TailcatServer(configuration: .init(identity: identity, relay: .region(302), allowedClients: [identity.publicKey])) XCTAssertEqual(server.publicKey, identity.publicKey) - let token = await server.token - XCTAssertNil(token) + let address = await server.address + XCTAssertNil(address) do { _ = try await server.status() XCTFail("status before start succeeded") @@ -378,10 +378,10 @@ final class ServerLifecycleTests: XCTestCase { } func testClientBeforeUse() async throws { - let token = try XCTUnwrap(ConnectionToken(rawValue: TokenTests.readmeToken)) + let address = try XCTUnwrap(TailcatAddress(rawValue: AddressTests.readmeAddress)) let identity = try Identity.generate() - let client = try TailcatClient(token: token, identity: identity, derpMapURL: URL(string: "https://example.invalid/derpmap.json")) - XCTAssertEqual(client.token, token) + let client = try TailcatClient(address: address, identity: identity, derpMapURL: URL(string: "https://example.invalid/derpmap.json")) + XCTAssertEqual(client.address, address) let key = try await client.publicKey XCTAssertEqual(key, identity.publicKey) do { diff --git a/swift/Tests/TailcatKitTests/TokenTests.swift b/swift/Tests/TailcatKitTests/TokenTests.swift deleted file mode 100644 index 4238537cc..000000000 --- a/swift/Tests/TailcatKitTests/TokenTests.swift +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -import Foundation -import TailcatKit -import XCTest - -/// Offline tests of ConnectionToken, TokenInfo and NodePublicKey. -final class TokenTests: XCTestCase { - /// The token in the README, referencing DERP region 302. - static let readmeToken = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu" - /// The same server's resolved token, with the relay embedded. - static let resolvedToken = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA" - static let readmeKey = "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34" - - func testParseReadmeToken() throws { - let token = try XCTUnwrap(ConnectionToken(rawValue: Self.readmeToken)) - XCTAssertEqual(token.rawValue, Self.readmeToken) - XCTAssertEqual(token.description, Self.readmeToken) - let info = try token.parse() - XCTAssertEqual(info.serverPublicKey.rawValue, Self.readmeKey) - XCTAssertEqual(info.regionID, 302) - XCTAssertEqual(info.relayHosts, []) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: info.json) as? [String: Any]) - XCTAssertEqual(json["RegionID"] as? Int, 302) - XCTAssertEqual(json["ServerPublic"] as? String, Self.readmeKey) - } - - func testParseResolvedToken() throws { - let token = try XCTUnwrap(ConnectionToken(rawValue: Self.resolvedToken)) - let info = try token.parse() - XCTAssertEqual(info.serverPublicKey.rawValue, Self.readmeKey) - XCTAssertNil(info.regionID) - XCTAssertEqual(info.relayHosts, ["tc302a.ipn.dev"]) - } - - func testInvalidPrefixIsNil() { - XCTAssertNil(ConnectionToken(rawValue: "nope")) - XCTAssertNil(ConnectionToken(rawValue: "")) - XCTAssertNil(ConnectionToken(rawValue: "tc")) - XCTAssertNotNil(ConnectionToken(rawValue: "tcx")) - } - - func testGarbageThrowsInvalidToken() throws { - let token = try XCTUnwrap(ConnectionToken(rawValue: "tcgarbage")) - XCTAssertThrowsError(try token.parse()) { error in - guard case TailcatError.invalidToken(let message) = error else { - return XCTFail("unexpected error \(error)") - } - XCTAssertFalse(message.isEmpty) - } - } - - func testTokenCodable() throws { - let token = try XCTUnwrap(ConnectionToken(rawValue: Self.readmeToken)) - let encoded = try JSONEncoder().encode([token]) - XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "[\"\(Self.readmeToken)\"]") - let decoded = try JSONDecoder().decode([ConnectionToken].self, from: encoded) - XCTAssertEqual(decoded, [token]) - XCTAssertThrowsError(try JSONDecoder().decode(ConnectionToken.self, from: Data("\"nope\"".utf8))) - } - - func testNodePublicKeyValidation() throws { - let key = try XCTUnwrap(NodePublicKey(rawValue: Self.readmeKey)) - XCTAssertEqual(key.rawValue, Self.readmeKey) - XCTAssertEqual(key.description, Self.readmeKey) - XCTAssertNil(NodePublicKey(rawValue: "9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34")) - XCTAssertNil(NodePublicKey(rawValue: "nodekey:9c8d2e67")) - XCTAssertNil(NodePublicKey(rawValue: "nodekey:" + String(repeating: "g", count: 64))) - XCTAssertNotNil(NodePublicKey(rawValue: "nodekey:" + String(repeating: "A", count: 64))) - let encoded = try JSONEncoder().encode([key]) - XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "[\"\(Self.readmeKey)\"]") - XCTAssertEqual(try JSONDecoder().decode([NodePublicKey].self, from: encoded), [key]) - } - - /// A DERP map that cannot be fetched is a network failure, not a bad - /// token: only a malformed token is reported as invalidToken. - func testResolveMapsNetworkFailures() async throws { - // Nothing listens on port 9 of the loopback interface, so the - // fetch is refused outright. - let refused = try XCTUnwrap(URL(string: "http://127.0.0.1:9/derpmap.json")) - let token = try XCTUnwrap(ConnectionToken(rawValue: Self.readmeToken)) - do { - _ = try await token.resolved(derpMapURL: refused, timeout: .seconds(5)) - XCTFail("resolving against a refused DERP map URL succeeded") - } catch TailcatError.invalidToken(let message) { - XCTFail("a network failure was reported as an invalid token: \(message)") - } catch let error as TailcatError { - if case .posix(let code, _) = error { - XCTAssertEqual(code, ECONNREFUSED) - } - } - // A malformed token is invalid whatever the map. - let garbage = try XCTUnwrap(ConnectionToken(rawValue: "tcgarbage")) - do { - _ = try await garbage.resolved(derpMapURL: refused, timeout: .seconds(5)) - XCTFail("resolving a malformed token succeeded") - } catch TailcatError.invalidToken { - } - // A token that embeds its relay resolves to itself, offline. - let embedded = try XCTUnwrap(ConnectionToken(rawValue: Self.resolvedToken)) - let same = try await embedded.resolved(derpMapURL: refused, timeout: .seconds(5)) - XCTAssertEqual(same, embedded) - } - - func testClientRejectsMalformedToken() throws { - let token = try XCTUnwrap(ConnectionToken(rawValue: "tcgarbage")) - XCTAssertThrowsError(try TailcatClient(token: token)) { error in - guard case TailcatError.invalidToken = error else { - return XCTFail("unexpected error \(error)") - } - } - } -} From 79719da7cf33698d758da369f61faee8563e8a55 Mon Sep 17 00:00:00 2001 From: Josue Diaz Flores Date: Wed, 2 Sep 2026 19:47:35 -0700 Subject: [PATCH 6/6] libtailcat, swift: drop first-ping retries now that pings resend --- libtailcat/README.md | 7 ++++++- libtailcat/include/tailcat.h | 18 ++++++++++-------- libtailcat/libtailcat_test.go | 15 +++------------ swift/README.md | 10 +++++----- swift/Sources/TailcatDemo/main.swift | 13 +------------ swift/Sources/TailcatKit/TailcatClient.swift | 7 ++++--- swift/Sources/TailcatKit/TailcatServer.swift | 7 ++++--- .../Tests/TailcatKitTests/EndToEndTests.swift | 15 +++------------ 8 files changed, 36 insertions(+), 56 deletions(-) diff --git a/libtailcat/README.md b/libtailcat/README.md index 0078c8b92..f5a040110 100644 --- a/libtailcat/README.md +++ b/libtailcat/README.md @@ -38,6 +38,11 @@ buffer and -1 for other errors, whose text `tailcat_errmsg` returns. Blocking calls (server start, client ping, path, dial, address resolve) do network work; keep them off UI threads. +`tailcat_server_start` returns once the server is configured and its +address is known; the relay connection completes in the background, and +pings resend until acknowledged, so a client's `tailcat_client_ping` +right after start succeeds within its timeout. + A server: ```c @@ -48,7 +53,7 @@ A server: tailcat_handle sd = tailcat_server_new(); tailcat_listener ln; tailcat_server_listen(sd, 8080, &ln); // 0 = every port not otherwise listened on -if (tailcat_server_start(sd) != 0) { // blocks: DERP map, latency check, relay connect +if (tailcat_server_start(sd) != 0) { // blocks: DERP map, latency check char err[256]; tailcat_errmsg(sd, err, sizeof err); // ... diff --git a/libtailcat/include/tailcat.h b/libtailcat/include/tailcat.h index 4855770c0..cfd64b99d 100644 --- a/libtailcat/include/tailcat.h +++ b/libtailcat/include/tailcat.h @@ -155,10 +155,11 @@ extern int tailcat_server_listen(tailcat_handle sd, int port, tailcat_listener* // the duration, typically a few seconds; never call it on a UI thread. A // server starts once. // -// Like the tailcat CLI, it returns once the server is configured; the -// connection to the relay itself completes in the background shortly -// after. A client pinging in that window gets no answer (see -// tailcat_client_ping) and should retry. +// Like the tailcat CLI, it returns once the server is configured and its +// address is known (see tailcat_server_addr); the connection to the relay +// itself completes in the background shortly after. Pings resend until +// acknowledged (see tailcat_client_ping), so a client pinging right after +// start succeeds within its timeout. // // Returns zero on success or -1 on error, call tailcat_errmsg for details. extern int tailcat_server_start(tailcat_handle sd); @@ -255,10 +256,11 @@ extern int tailcat_client_public_key(tailcat_handle cd, char* buf, size_t buflen // NULL). It blocks for up to timeout_ms milliseconds; a timeout of 0 or // less means no limit beyond tailcat's own internal one. // -// Each call sends one probe. A server that doesn't allow this client never -// answers, so a rejected client shows up as a timeout; so does a server -// that is still connecting to its relay right after tailcat_server_start, -// which is worth a retry. +// The probe is resent every second until the server acknowledges it or +// the timeout expires, so a server still connecting to its relay right +// after tailcat_server_start is reached as soon as it is on the relay. A +// server that doesn't allow this client never answers, so a rejected +// client shows up as a timeout. // // Returns zero on success or -1 on error, call tailcat_errmsg for details. extern int tailcat_client_ping(tailcat_handle cd, int timeout_ms, double* latency_ms_out); diff --git a/libtailcat/libtailcat_test.go b/libtailcat/libtailcat_test.go index 1b2497366..441160992 100644 --- a/libtailcat/libtailcat_test.go +++ b/libtailcat/libtailcat_test.go @@ -322,18 +322,9 @@ func TestEndToEnd(t *testing.T) { check(t, "allow client", sd, TailcatServerAllowClient(sd, ckey)) cFree(ckey) // The server finishes connecting to the relay in the background - // after start, and a ping sends a single meow that a server not yet - // on the relay never sees, so retry until one gets through. - deadline := time.Now().Add(30 * time.Second) - for { - if rc := TailcatClientPing(cd, 2000, &latency); rc == 0 { - break - } - if time.Now().After(deadline) { - t.Fatalf("ping: %s", errmsg(cd)) - } - t.Logf("ping: %s; retrying", errmsg(cd)) - } + // after start; the ping resends its meow until the server answers, + // so one call with a generous timeout suffices. + check(t, "ping", cd, TailcatClientPing(cd, 15000, &latency)) if latency <= 0 { t.Fatalf("ping latency = %v ms; want > 0", latency) } diff --git a/swift/README.md b/swift/README.md index 516da8ebf..de816d397 100644 --- a/swift/README.md +++ b/swift/README.md @@ -55,7 +55,7 @@ A client: ```swift let client = try TailcatClient(address: address) -let rtt = try await client.ping() // brings the tunnel up; retry on .timeout right after the server started +let rtt = try await client.ping() // brings the tunnel up let path = try await client.path() // direct endpoint or relay region let connection = try await client.connect(port: 8080) try await connection.send(Data("hello\n".utf8)) @@ -94,10 +94,10 @@ stable across restarts); `.region(id)` and `.hosts([...])` override it. data has been handed to the tunnel, `closeWrite` is a TCP half-close, and `close` (also run by deinit) closes the descriptor exactly once. One receive at a time; `incoming` is a pull-based stream over it. -- `start()` returns once the server is configured, like the tailcat CLI; - the relay connection completes in the background right after, so a - client's first `ping` may throw `TailcatError.timeout` and is worth - retrying. +- `start()` returns once the server is configured and its address is + known, like the tailcat CLI; the relay connection completes in the + background right after, and pings resend until acknowledged, so a + client's `ping` right after `start()` succeeds within its timeout. - Closing a server or client also closes its listeners and connections on the Go side: their reads see EOF and their accepts throw `TailcatError.closed`. The Swift objects still own their descriptors diff --git a/swift/Sources/TailcatDemo/main.swift b/swift/Sources/TailcatDemo/main.swift index 80bff4c80..7ec352cf1 100644 --- a/swift/Sources/TailcatDemo/main.swift +++ b/swift/Sources/TailcatDemo/main.swift @@ -76,18 +76,7 @@ func serve(port: UInt16) async throws { func connect(address: TailcatAddress, port: UInt16) async throws { let client = try TailcatClient(address: address, logger: makeLogger()) - // A server that just started may still be connecting to its relay, - // in which case the first probe times out; try a few times. - var latency: Duration? - for attempt in 1...4 { - do { - latency = try await client.ping(timeout: .seconds(5)) - break - } catch TailcatError.timeout where attempt < 4 { - note("# ping timed out, retrying") - } - } - guard let latency else { fail("no answer from the server") } + let latency = try await client.ping(timeout: .seconds(15)) note("# pong in \(milliseconds(latency)) via the relay") let path = try await client.path() if path.isDirect { diff --git a/swift/Sources/TailcatKit/TailcatClient.swift b/swift/Sources/TailcatKit/TailcatClient.swift index 9b07ff00c..5a99aff92 100644 --- a/swift/Sources/TailcatKit/TailcatClient.swift +++ b/swift/Sources/TailcatKit/TailcatClient.swift @@ -75,9 +75,10 @@ public actor TailcatClient { /// Checks that the server is reachable and accepts this client, and /// returns the relay round trip (tailcat_client_ping, off-thread). - /// Each call sends one probe: a server that does not allow this - /// client, or one still connecting to its relay right after starting, - /// shows up as TailcatError.timeout, which is worth a retry. The + /// The probe is resent every second until acknowledged or the timeout + /// expires, so a ping right after the server started succeeds as soon + /// as the server is on its relay; a server that does not allow this + /// client never answers, which shows up as TailcatError.timeout. The /// timeout is rounded up to whole milliseconds; zero means no limit /// beyond tailcat's own. public func ping(timeout: Duration = .seconds(10)) async throws -> Duration { diff --git a/swift/Sources/TailcatKit/TailcatServer.swift b/swift/Sources/TailcatKit/TailcatServer.swift index b1e45760d..28e7e8163 100644 --- a/swift/Sources/TailcatKit/TailcatServer.swift +++ b/swift/Sources/TailcatKit/TailcatServer.swift @@ -103,9 +103,10 @@ public actor TailcatServer { /// relay, fetching the DERP map and measuring latencies as needed, and /// returns the tailcat address, also kept in `address`. Throws /// TailcatError.alreadyStarted on a second call. Like the tailcat CLI - /// it returns once the server is configured; the relay connection - /// completes in the background right after, so a client pinging - /// within the first seconds may time out and should retry. + /// it returns once the server is configured and its address is known; + /// the relay connection completes in the background right after, and + /// pings resend until acknowledged, so a client pinging right after + /// start succeeds within its timeout. public func start() async throws -> TailcatAddress { switch state { case .closed: diff --git a/swift/Tests/TailcatKitTests/EndToEndTests.swift b/swift/Tests/TailcatKitTests/EndToEndTests.swift index 2745a1309..c00eabfd9 100644 --- a/swift/Tests/TailcatKitTests/EndToEndTests.swift +++ b/swift/Tests/TailcatKitTests/EndToEndTests.swift @@ -58,18 +58,9 @@ final class EndToEndTests: XCTestCase { } let client = try TailcatClient(address: address, logger: BlackholeLogger()) - // The relay connection completes shortly after start() returns, - // so the first probe may time out. - var latency: Duration? - for attempt in 1...6 { - do { - latency = try await client.ping(timeout: .seconds(5)) - break - } catch TailcatError.timeout where attempt < 6 { - continue - } - } - let rtt = try XCTUnwrap(latency) + // The relay connection completes shortly after start() returns; + // pings resend until acknowledged, so one ping suffices. + let rtt = try await client.ping(timeout: .seconds(15)) XCTAssertGreaterThan(rtt, .zero) let path = try await client.path()