From a843aee09780cbaacb7a4302cf27de5a3c5eded9 Mon Sep 17 00:00:00 2001 From: Vladimir Vukicevic Date: Tue, 22 Sep 2026 11:33:22 -0700 Subject: [PATCH 1/2] Expose Tailcat through a cancellable C-compatible shared library API --- .github/workflows/test.yml | 6 + cmd/libtailcat/README.md | 113 ++++++ cmd/libtailcat/main.go | 257 ++++++++++++++ cmd/libtailcat/tailcat.h | 407 ++++++++++++++++++++++ cmd/libtailcat/testdata/smoke.c | 60 ++++ internal/capi/api.go | 585 ++++++++++++++++++++++++++++++++ internal/capi/api_test.go | 138 ++++++++ internal/capi/e2e_test.go | 272 +++++++++++++++ internal/capi/registry.go | 257 ++++++++++++++ internal/capi/token_test.go | 140 ++++++++ internal/capitest/main.go | 148 ++++++++ listen.go | 17 + tailcat.go | 8 +- 13 files changed, 2407 insertions(+), 1 deletion(-) create mode 100644 cmd/libtailcat/README.md create mode 100644 cmd/libtailcat/main.go create mode 100644 cmd/libtailcat/tailcat.h create mode 100644 cmd/libtailcat/testdata/smoke.c create mode 100644 internal/capi/api.go create mode 100644 internal/capi/api_test.go create mode 100644 internal/capi/e2e_test.go create mode 100644 internal/capi/registry.go create mode 100644 internal/capi/token_test.go create mode 100644 internal/capitest/main.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13df00a60..1f1992e93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,12 @@ jobs: # long timeout. actions/setup-go's caches keep warm runs fast. - run: go test -count=1 -timeout 600s ./... - run: go vet ./... + - name: build libtailcat and run its C smoke test + if: runner.os != 'Windows' + run: | + CGO_ENABLED=1 go build -buildmode=c-shared -o /tmp/libtailcat.so ./cmd/libtailcat + cc -Wall -Wextra -Werror -o /tmp/smoke cmd/libtailcat/testdata/smoke.c -Icmd/libtailcat /tmp/libtailcat.so + LD_LIBRARY_PATH=/tmp DYLD_LIBRARY_PATH=/tmp /tmp/smoke tidy: runs-on: ubuntu-latest steps: diff --git a/cmd/libtailcat/README.md b/cmd/libtailcat/README.md new file mode 100644 index 000000000..5bb83f1c2 --- /dev/null +++ b/cmd/libtailcat/README.md @@ -0,0 +1,113 @@ +# libtailcat C API + +Build the shared library and generated linker header from the Tailcat module: + +```sh +CGO_ENABLED=1 go build -buildmode=c-shared -o libtailcat.so ./cmd/libtailcat +``` + +Use `.dylib` on macOS and `.dll` on Windows. A C compiler and the Go toolchain +specified by `go.mod` are required. Production builds should use the tags in +`build-tags.txt`. `tailcat.h` is the public ABI definition; the generated +`libtailcat.h` is a build artifact. ABI version 1 is returned by `tc_abi_version`. + +## Ownership and errors + +All handles are opaque 64-bit integers. Zero is invalid as a resource handle; +only token arguments accept it as the `TC_NO_CANCEL` sentinel. A handle is +never reused within a process. Handles are checked for existence and resource type. +`tc_close` retires a handle, interrupts active calls, and releases its resources. +Calling it on a retired handle returns `TC_CLOSED`. + +Clients and servers own their connections. Servers also own listeners. Accepted +connections are children of the server: closing a listener does not close its +accepted connections. Closing a client/server closes its complete resource tree. +An active function retains its Go objects until it returns, even during close. + +Every fallible function returns a `TC_*` status and optionally writes an allocated +UTF-8 error string through `char **error`. Every returned string, including JSON, +must be released with `tc_free`. Output string/handle pointers must be non-null; +the error pointer may be null. Inputs are borrowed for the duration of the call. +Strings are NUL-terminated UTF-8. Input pointers must identify valid memory. +No function retains caller buffers or returns pointers into Go memory. + +`tc_conn_read` and `tc_conn_write` always set their byte count, including on error. +Process those bytes before the error. TCP EOF is `TC_EOF`; a zero-length UDP +packet is `TC_OK` with count zero. UDP writes above 1232 bytes are rejected. +Read/write calls may be partial. One read and one write may proceed concurrently; +same-direction calls are serialized and their queue time counts toward deadlines. + +## Tokens and cancellation + +All calls are synchronous. If no caller deadline or explicit cancellation is +needed, pass `TC_NO_CANCEL` (zero) as the token argument: + +```c +size_t count = 0; +char *error = NULL; +int32_t status = tc_conn_read(connection, TC_NO_CANCEL, + buffer, sizeof(buffer), &count, &error); +/* Process count bytes, then handle status. */ +tc_free(error); +``` + +This allocates no token and requires no cleanup. Closing the connection, listener, +or owning peer still interrupts its calls, and internal protocol timeouts still +apply. `tc_token_cancel(TC_NO_CANCEL, ...)` and +`tc_close(TC_NO_CANCEL, ...)` return `TC_CLOSED`. Take care with zero-initialized +token variables: passing zero now allows a call to wait indefinitely instead +of reporting an invalid handle. + +Create a `tc_token` with `tc_token_new(timeout_ns, ...)`. The timeout begins +at creation; `-1` disables the deadline, zero expires immediately. Pass that handle +to blocking functions, then close it. `tc_token_cancel` is nonblocking and may +run on another thread. Closing a token cancels it too. Cancellation of a +pending accept does not close its listener. Cancellation of read/write interrupts +that direction and leaves the connection available for a later I/O call. + +`tc_token_new(-1, ...)` still allocates a cancellable token; it is not the same +as `TC_NO_CANCEL`. Async language wrappers must retain real tokens even when +no timeout is configured, so task cancellation can interrupt their worker threads. + +A token cancelled just as a handle-producing call succeeds may still return +a handle: the caller owns and must close that result. Do not free buffers before +the original call returns. `tc_conn_readable` is a non-consuming, nonblocking +TCP probe intended for connection-pool expiry; it reports data, EOF, and errors +as readable. It is not an OS file descriptor or a readiness subscription API. + +## Configuration + +`tc_client_new` accepts JSON with `address` (required), `key`, and `derp_map_url`. +`tc_server_new` accepts `key`, `preshared_key`, `region`, `region_id`, +`derp_map_url`, `allowed_clients`, and `udp_idle_timeout` (seconds). Unknown +fields and invalid keys are errors. Logging is discarded by default. + +Keys use Tailcat/Tailscale text encodings. `tc_key_generate` returns JSON with +`private_key`, `public_key`, and `preshared_key`. Persist both private and pre-shared +keys to preserve server identity across restarts. `region` is a Tailscale +DERPRegion in its JSON encoding, with Go field names such as `RegionID` and +`Nodes`; that encoding is part of ABI version 1. Omitting it uses relay +discovery. The default relay map is Tailcat's public map. + +`tc_server_start` is idempotent at the C API layer. Listening also starts a server. +Unknown configuration fields are reported by name; other configuration errors +are generic so that error text never echoes secrets. +Use `TC_TCP` or `TC_UDP` and a numerical port; listen port zero chooses a free port. +`tc_client_dial` connects directly to a service port on the configured peer. + +`tc_info` returns JSON specific to the handle: client public key, server address +and public key, listener local address, or connection local/remote addresses. +Server info requires startup. Endpoint strings are in host:port form (IPv6 hosts +are bracketed). `tc_client_ping` writes relay latency to `int32_t *ping_ms` in +whole milliseconds (fractional milliseconds are truncated). `tc_client_disco_ping` +returns JSON with latency in seconds, endpoint, and DERP-region information. +`tc_drain` waits for TCP shutdown +using the supplied token deadline, and is a no-op before startup. + +`tc_address_parse` returns public metadata without the pre-shared key. +`tc_address_resolve` embeds relay information in an address. Addresses and generated +private/pre-shared keys are secrets and should not be included in logs. + +This shared library embeds a Go runtime. Keep it loaded for the process lifetime; +do not use it after `fork` without `exec`. This ABI exposes network primitives, +not the Tailcat CLI's SSH, file-sharing, or arbitrary forwarding services. diff --git a/cmd/libtailcat/main.go b/cmd/libtailcat/main.go new file mode 100644 index 000000000..210309411 --- /dev/null +++ b/cmd/libtailcat/main.go @@ -0,0 +1,257 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Command libtailcat exports Tailcat through a versioned, C-compatible ABI. +package main + +/* +#include "tailcat.h" +#include +*/ +import "C" + +import ( + "fmt" + "os" + "runtime/debug" + "unsafe" + + "github.com/tailscale/tailcat/internal/capi" +) + +func main() {} + +func guard(out **C.char, fn func() error) (code C.int32_t) { + if out != nil { + *out = nil + } + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "libtailcat: internal failure: %v\n%s", r, debug.Stack()) + code = C.int32_t(capi.Failure) + if out != nil { + *out = C.CString(fmt.Sprintf("internal tailcat failure: %v", r)) + } + } + }() + if err := fn(); err != nil { + if out != nil { + *out = C.CString(err.Error()) + } + return C.int32_t(capi.ErrorCode(err)) + } + return 0 +} + +func handle(out *C.tc_handle, fn func() (capi.Handle, error)) error { + if out == nil { + return capi.ErrArgument + } + *out = 0 + h, err := fn() + if err == nil { + *out = C.tc_handle(h) + } + return err +} + +func text(out **C.char, fn func() (string, error)) error { + if out == nil { + return capi.ErrArgument + } + *out = nil + s, err := fn() + if err == nil { + *out = C.CString(s) + } + return err +} + +//export tc_abi_version +func tc_abi_version() C.uint32_t { return capi.ABIVersion } + +//export tc_build_info +func tc_build_info(out, err **C.char) C.int32_t { + return guard(err, func() error { return text(out, func() (string, error) { return capi.BuildInfo(), nil }) }) +} + +//export tc_token_new +func tc_token_new(ns C.int64_t, out *C.tc_token, err **C.char) C.int32_t { + return guard(err, func() error { return handle(out, func() (capi.Handle, error) { return capi.NewToken(int64(ns)) }) }) +} + +//export tc_token_cancel +func tc_token_cancel(token C.tc_token, err **C.char) C.int32_t { + return guard(err, func() error { return capi.Cancel(capi.Handle(token)) }) +} + +//export tc_close +func tc_close(id C.tc_handle, err **C.char) C.int32_t { + return guard(err, func() error { return capi.Close(capi.Handle(id)) }) +} + +//export tc_free +func tc_free(ptr unsafe.Pointer) { C.free(ptr) } + +//export tc_client_new +func tc_client_new(config *C.char, out *C.tc_handle, err **C.char) C.int32_t { + return guard(err, func() error { + return handle(out, func() (capi.Handle, error) { return capi.NewClient(C.GoString(config)) }) + }) +} + +//export tc_server_new +func tc_server_new(config *C.char, out *C.tc_handle, err **C.char) C.int32_t { + return guard(err, func() error { + return handle(out, func() (capi.Handle, error) { return capi.NewServer(C.GoString(config)) }) + }) +} + +//export tc_client_dial +func tc_client_dial(id C.tc_handle, token C.tc_token, port C.uint16_t, network C.int32_t, out *C.tc_handle, err **C.char) C.int32_t { + return guard(err, func() error { + return handle(out, func() (capi.Handle, error) { + return capi.Dial(capi.Handle(id), capi.Handle(token), uint16(port), int(network)) + }) + }) +} + +//export tc_server_start +func tc_server_start(id C.tc_handle, token C.tc_token, err **C.char) C.int32_t { + return guard(err, func() error { return capi.Start(capi.Handle(id), capi.Handle(token)) }) +} + +//export tc_server_listen +func tc_server_listen(id C.tc_handle, token C.tc_token, port C.uint16_t, network C.int32_t, out *C.tc_handle, err **C.char) C.int32_t { + return guard(err, func() error { + return handle(out, func() (capi.Handle, error) { + return capi.Listen(capi.Handle(id), capi.Handle(token), uint16(port), int(network)) + }) + }) +} + +//export tc_listener_accept +func tc_listener_accept(id C.tc_handle, token C.tc_token, out *C.tc_handle, err **C.char) C.int32_t { + return guard(err, func() error { + return handle(out, func() (capi.Handle, error) { return capi.Accept(capi.Handle(id), capi.Handle(token)) }) + }) +} + +func buffer(ptr unsafe.Pointer, n C.size_t) ([]byte, error) { + if uint64(n) > uint64(^uint(0)>>1) || (ptr == nil && n != 0) { + return nil, capi.ErrArgument + } + return unsafe.Slice((*byte)(ptr), int(n)), nil +} + +//export tc_conn_read +func tc_conn_read(id C.tc_handle, token C.tc_token, ptr unsafe.Pointer, size C.size_t, count *C.size_t, err **C.char) C.int32_t { + return guard(err, func() error { + if count == nil { + return capi.ErrArgument + } + *count = 0 + b, err := buffer(ptr, size) + if err != nil { + return err + } + n, err := capi.Read(capi.Handle(id), capi.Handle(token), b) + *count = C.size_t(n) + return err + }) +} + +//export tc_conn_write +func tc_conn_write(id C.tc_handle, token C.tc_token, ptr unsafe.Pointer, size C.size_t, count *C.size_t, err **C.char) C.int32_t { + return guard(err, func() error { + if count == nil { + return capi.ErrArgument + } + *count = 0 + b, err := buffer(ptr, size) + if err != nil { + return err + } + n, err := capi.Write(capi.Handle(id), capi.Handle(token), b) + *count = C.size_t(n) + return err + }) +} + +//export tc_conn_close_write +func tc_conn_close_write(id C.tc_handle, token C.tc_token, err **C.char) C.int32_t { + return guard(err, func() error { return capi.CloseWrite(capi.Handle(id), capi.Handle(token)) }) +} + +//export tc_conn_readable +func tc_conn_readable(id C.tc_handle, out *C.int32_t, err **C.char) C.int32_t { + return guard(err, func() error { + if out == nil { + return capi.ErrArgument + } + *out = 0 + ready, err := capi.Readable(capi.Handle(id)) + if ready { + *out = 1 + } + return err + }) +} + +//export tc_info +func tc_info(id C.tc_handle, token C.tc_token, out, err **C.char) C.int32_t { + return guard(err, func() error { + return text(out, func() (string, error) { return capi.Info(capi.Handle(id), capi.Handle(token)) }) + }) +} + +//export tc_client_ping +func tc_client_ping(id C.tc_handle, token C.tc_token, pingMS *C.int32_t, err **C.char) C.int32_t { + return guard(err, func() error { + if pingMS == nil { + return capi.ErrArgument + } + *pingMS = 0 + ms, err := capi.Ping(capi.Handle(id), capi.Handle(token)) + if err == nil { + *pingMS = C.int32_t(ms) + } + return err + }) +} + +//export tc_client_disco_ping +func tc_client_disco_ping(id C.tc_handle, token C.tc_token, out, err **C.char) C.int32_t { + return guard(err, func() error { + return text(out, func() (string, error) { return capi.DiscoPing(capi.Handle(id), capi.Handle(token)) }) + }) +} + +//export tc_server_allow_client +func tc_server_allow_client(id C.tc_handle, token C.tc_token, pub *C.char, err **C.char) C.int32_t { + return guard(err, func() error { return capi.AllowClient(capi.Handle(id), capi.Handle(token), C.GoString(pub)) }) +} + +//export tc_drain +func tc_drain(id C.tc_handle, token C.tc_token, err **C.char) C.int32_t { + return guard(err, func() error { return capi.Drain(capi.Handle(id), capi.Handle(token)) }) +} + +//export tc_address_parse +func tc_address_parse(addr *C.char, out, err **C.char) C.int32_t { + return guard(err, func() error { return text(out, func() (string, error) { return capi.ParseAddress(C.GoString(addr)) }) }) +} + +//export tc_address_resolve +func tc_address_resolve(token C.tc_token, addr, mapURL *C.char, out, err **C.char) C.int32_t { + return guard(err, func() error { + return text(out, func() (string, error) { + return capi.ResolveAddress(capi.Handle(token), C.GoString(addr), C.GoString(mapURL)) + }) + }) +} + +//export tc_key_generate +func tc_key_generate(out, err **C.char) C.int32_t { + return guard(err, func() error { return text(out, capi.GenerateKey) }) +} diff --git a/cmd/libtailcat/tailcat.h b/cmd/libtailcat/tailcat.h new file mode 100644 index 000000000..eee5929a7 --- /dev/null +++ b/cmd/libtailcat/tailcat.h @@ -0,0 +1,407 @@ +/* + * Copyright (c) Tailscale Inc & contributors + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef LIBTAILCAT_H +#define LIBTAILCAT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * ABI conventions: + * + * Threading and deadlines + * + * Calls are synchronous, including those taking a token handle. A token supplies + * cancellation and an optional deadline; it is not a future or job. A thread can + * cancel it while the calling thread is blocked inside this API. + * Different handles, and one read plus one write on a connection, may be used + * concurrently. Same-direction I/O is serialized; queue time counts toward + * the token deadline. + * + * Any token parameter may instead be TC_NO_CANCEL: no caller deadline + * and no explicit cancellation token. Resource closure still interrupts the + * call, and internal protocol timeouts still apply. + * + * Status codes and error messages + * + * Except tc_abi_version and tc_free, functions return a TC_* status. TC_OK means + * success; TC_EOF marks TCP end-of-stream. error itself may be NULL to discard + * the message. Otherwise *error is set to NULL on success or to an allocated + * NUL-terminated UTF-8 copy on failure. This copy is writable, caller-owned + * memory, NOT borrowed internal storage: release it with tc_free. Branch on + * status codes, not error text. Free a previous message before reusing its slot. + * + * Outputs and ownership + * + * out and count pointers are required. Handle/string outputs are initialized + * to zero/NULL before work begins. Returned strings, including JSON, belong to + * the caller: release them with tc_free. Release returned handles with tc_close. + * Handles are opaque, nonzero, type-checked, and never reused in this process. + * Do not share output storage between concurrent calls. + * + * Inputs and cancellation + * + * Strings are NUL-terminated UTF-8. Unless stated otherwise, pointers must name + * valid, non-NULL memory. Inputs are borrowed only until the call returns; no + * caller buffer is retained and no Go pointer is returned. Even after cancelling, + * keep buffers/output storage alive until the ORIGINAL call returns. Completion + * may win a cancellation race; the caller still owns any successful result. + * Cancellation never rolls back bytes sent or other completed side effects. + * + * Process lifetime and secrets + * + * The shared library embeds a Go runtime. Keep it loaded for the process lifetime + * and do not use it after fork without exec. Tailcat addresses and private and + * pre-shared keys are secrets; do not log them. See README.md for build details. + */ + +/* BEGIN CFFI */ + +typedef uint64_t tc_handle; + +/** Cancellation/deadline token; a handle released with tc_close when finished. */ +typedef tc_handle tc_token; + +/** + * Use as a token argument for synchronous calls with no caller deadline + * or explicit cancellation token. No token is allocated or needs releasing. + * + * Closing the underlying resource still interrupts the call. Internal protocol + * timeouts still apply. This is not a valid resource handle: tc_close and + * tc_token_cancel return TC_CLOSED when passed TC_NO_CANCEL. + * + * Unlike this sentinel, tc_token_new(-1, ...) creates a cancellable token + * with no deadline. A timeout_ns of 0 creates an immediately expired token. + */ +#define TC_NO_CANCEL 0 + +enum { + TC_OK = 0, + TC_INVALID_ARGUMENT = 1, + TC_CLOSED = 2, + TC_TIMEOUT = 3, + TC_CANCELLED = 4, + TC_FAILURE = 5, + TC_EOF = 6 +}; + +enum { + TC_TCP = 1, + TC_UDP = 2 +}; + +/* Version and build information */ + +/** + * Return the C ABI version (currently 1); this call cannot fail. + */ +uint32_t tc_abi_version(void); + +/** + * Return allocated JSON containing abi_version and Go build metadata in *out. + * + * No network access is performed. Release *out with tc_free. + */ +int32_t tc_build_info(char **out, char **error); + +/* Cancellation and lifetime */ + +/** + * Create a cancellation/deadline token in *out without starting any I/O. + * + * timeout_ns is nanoseconds from token creation, NOT from each later call: + * -1 disables the deadline, 0 expires immediately, and values below -1 fail. + * + * Multiple calls may share one token/deadline (e.g. a TLS handshake or send loop). + * Keep it until all such calls return, then release it with tc_close. Tokens do + * not reset after use or cancellation; create a new token for a fresh deadline. + * + * If neither a deadline nor explicit cancellation is needed, pass TC_NO_CANCEL + * to the I/O function instead of creating a token. + */ +int32_t tc_token_new(int64_t timeout_ns, tc_token *out, char **error); + +/** + * Signal cancellation of work using cancellation_token; safe from another thread. + * + * Does not wait for blocked calls to return, close their connections/listeners, + * or release the token. Interrupted calls normally return TC_CANCELLED, but + * completion may win the race. + * + * Cancelling an already cancelled live token succeeds. Wait for original calls + * before releasing their buffers/token. + * + * TC_NO_CANCEL is not a token; attempting to cancel it returns TC_CLOSED. + */ +int32_t tc_token_cancel(tc_token cancellation_token, char **error); + +/** + * Retire a handle and release its resources; later use returns TC_CLOSED. + * + * Clients/servers also close their connections and, for servers, their listeners. + * Closing a listener leaves accepted connections alive. + * + * Closing these resources interrupts and waits for active calls on their retired + * resource tree; this is synchronous and has no timeout. Closing a token instead + * cancels/releases it and does NOT join calls using it. + * + * Unknown/already-closed handles return TC_CLOSED. Even if shutdown reports + * another error, the handle is retired. + * + * TC_NO_CANCEL has nothing to release; passing it here returns TC_CLOSED. + */ +int32_t tc_close(tc_handle resource, char **error); + +/** + * Free a string/error allocation returned by this API; NULL is a no-op. + * + * Do not pass handles, buffers allocated by the caller, or an allocation already + * freed. + */ +void tc_free(void *allocation); + +/* Clients, servers, and listeners */ + +/** + * Create a client in *out; network startup is deferred until dialing/pinging. + * + * config_json contains required address (secret Tailcat address), optional key + * (text-encoded private node key; otherwise generated), and derp_map_url (relay-map + * override). Unknown fields/invalid keys are rejected. + * + * The client owns its dialed connections. Release it with tc_close. + */ +int32_t tc_client_new(char *config_json, tc_handle *out, char **error); + +/** + * Create an unstarted server in *out from JSON ({} uses defaults). + * + * Optional fields: key (private node key), preshared_key, region, region_id, + * derp_map_url, allowed_clients (array of public node keys), udp_idle_timeout + * (nonnegative seconds; zero uses the default). + * + * region is a Tailscale DERPRegion in its JSON encoding (Go field names such as + * RegionID and Nodes). That encoding is part of ABI version 1; fields added to + * DERPRegion later may be accepted but are not part of this ABI. + * + * Omitted keys are generated; an empty allowlist permits clients possessing the + * address. Unknown fields/invalid keys are rejected. + * + * Start/listen performs network startup. Release the server with tc_close. + */ +int32_t tc_server_new(char *config_json, tc_handle *out, char **error); + +/** + * Synchronously dial port (1..65535) on client's configured Tailcat peer. + * + * network must be TC_TCP or TC_UDP. Startup/registration and dialing share + * the token's deadline. + * + * *out receives a TCP stream or connected UDP flow owned by client; release it + * with tc_close. This does not dial arbitrary destinations. + */ +int32_t tc_client_dial(tc_handle client, tc_token cancellation_token, + uint16_t port, int32_t network, tc_handle *out, char **error); + +/** + * Start server synchronously using cancellation_token. + * + * Repeated starts succeed with an active token. Afterwards tc_info can return + * its shareable secret address. Listening also starts a server automatically. + */ +int32_t tc_server_start(tc_handle server, tc_token cancellation_token, + char **error); + +/** + * Start server if needed and create a TC_TCP or TC_UDP listener in *out. + * + * port == 0 chooses a free port; retrieve it from tc_info's local_address. + * + * The server owns the listener. tc_listener_accept receives connections/flows; + * tc_close stops accepting new ones without closing previously accepted ones. + */ +int32_t tc_server_listen(tc_handle server, tc_token cancellation_token, + uint16_t port, int32_t network, tc_handle *out, char **error); + +/** + * Wait synchronously for a TCP connection or a new connected UDP flow. + * + * *out receives a connection owned by the listener's SERVER, not its listener: + * it survives listener closure but not server closure. + * + * Cancellation/timeout interrupts only this accept, leaving the listener usable. + * Close a successful result with tc_close even if cancellation raced with its + * delivery. + */ +int32_t tc_listener_accept(tc_handle listener, tc_token cancellation_token, + tc_handle *out, char **error); + +/* Connection I/O */ + +/** + * Read into buffer (capacity bytes), setting *count even when an error occurs. + * + * TCP reads may be short; TC_EOF marks end-of-stream. A zero-capacity TCP read + * succeeds with count zero and is not an EOF probe. + * + * For UDP, one call consumes one datagram, truncating it if capacity is too small; + * an empty datagram returns TC_OK with count zero. buffer may be NULL only when + * capacity is zero. + * + * Process returned bytes before the status. Cancellation/timeout ends this read + * without closing the connection. The call is synchronous; another thread can + * interrupt its wait with tc_token_cancel. + */ +int32_t tc_conn_read(tc_handle connection, tc_token cancellation_token, + void *buffer, size_t capacity, size_t *count, char **error); + +/** + * Write from buffer (length bytes), setting *count even when an error occurs. + * + * TCP writes may be short: loop with the same token for one overall deadline. + * UDP sends one datagram: length must be <=1232; zero sends an empty datagram. + * buffer may be NULL only when length is zero. + * + * Bytes in *count may already have been transmitted on timeout/cancellation; + * do not blindly replay the buffer. + * + * Cancellation leaves the raw connection open, but application protocols may + * require closing it after partial writes. + */ +int32_t tc_conn_write(tc_handle connection, tc_token cancellation_token, + void *buffer, size_t length, size_t *count, char **error); + +/** + * Half-close TCP writes (send FIN) while retaining the ability to read. + * + * Waits behind pending writes under the token's deadline/cancellation. Does not + * release the handle; eventually use tc_close. UDP does not support half-close. + * + * Return does not guarantee FIN acknowledgment; use tc_drain before peer shutdown. + */ +int32_t tc_conn_close_write(tc_handle connection, tc_token cancellation_token, + char **error); + +/** + * Probe TCP without consuming bytes or waiting for network data. + * + * On TC_OK, *out is 1 for buffered data, EOF, or a pending read error, otherwise + * 0. A concurrent reader can cause a 0 result without inspecting its data. + * + * Intended for idle pool expiry, not a readiness subscription/OS descriptor. + * UDP is unsupported. Check the status before interpreting *out. + */ +int32_t tc_conn_readable(tc_handle connection, int32_t *out, char **error); + +/* Peer information and administration */ + +/** + * Return allocated JSON describing resource (not a token) in *out. + * + * Client: public_key. Started server: address, local_address, public_key. + * Listener: local_address, network. Connection: local_address, remote_address, + * network. + * + * Listener/connection endpoints use host:port (IPv6 in brackets), and network is + * TC_TCP/TC_UDP. Server info fails before startup and contains a secret address + * afterwards. Release the JSON with tc_free. + */ +int32_t tc_info(tc_handle resource, tc_token cancellation_token, + char **out, char **error); + +/** + * Start/register client if needed, then ping its peer using cancellation_token. + * + * Measure the DERP path and return its round-trip latency in *ping_ms, in whole + * milliseconds (fractional milliseconds are truncated; zero is valid). + * + * ping_ms is required and is initialized to zero before work begins. + * Tailcat's internal ping timeout may finish before the token's deadline. + */ +int32_t tc_client_ping(tc_handle client, tc_token cancellation_token, + int32_t *ping_ms, char **error); + +/** + * Start/register client if needed, then probe discovery using cancellation_token. + * + * Return allocated JSON in *out with latency (seconds), endpoint, derp_region_id, + * and derp_region_code. endpoint identifies a direct path; otherwise the DERP + * fields identify the relay. This ping also triggers direct path discovery. + * + * Release the JSON with tc_free. Use a token with a deadline to bound waiting: + * discovery may wait indefinitely if no pong arrives and TC_NO_CANCEL is used. + */ +int32_t tc_client_disco_ping(tc_handle client, tc_token cancellation_token, + char **out, char **error); + +/** + * Add a text-encoded public node key to server's allowlist before/after startup. + * + * The token bounds waiting for server configuration access. An empty list + * permits any client possessing the address; the first entry restricts subsequent + * registration to allowed keys. Does not remove keys or disconnect existing flows. + */ +int32_t tc_server_allow_client(tc_handle server, tc_token cancellation_token, + char *public_key, char **error); + +/** + * Wait for a client's/server's TCP stack to finish shutdown traffic. + * + * Call after closing its connections or reaching EOF, BEFORE closing the peer + * or exiting. Does not itself close handles/connections; an unstarted peer is a + * no-op with an active token. + * + * Use a finite deadline: absent peers or server-side TCP TIME-WAIT can delay + * draining. Listener/connection/token handles fail. + */ +int32_t tc_drain(tc_handle resource, tc_token cancellation_token, char **error); + +/* Addresses and keys */ + +/** + * Parse address locally into allocated public-metadata JSON in *out. + * + * Fields: public_key, disco_public_key, has_preshared_key, region_id, regions. + * Deliberately omits the pre-shared key value. No network/relay lookup occurs. + * Release *out with tc_free. + */ +int32_t tc_address_parse(char *address, char **out, char **error); + +/** + * Return an allocated ADDRESS string (not JSON) with relay details embedded. + * + * May fetch the map using cancellation_token. derp_map_url may be NULL or empty + * to select the default map. Already self-contained addresses need no lookup. + * + * The result pins relay details, avoiding later map discovery (connecting still + * requires network access). Input is unchanged. Treat *out as secret; free with + * tc_free. + */ +int32_t tc_address_resolve(tc_token cancellation_token, char *address, + char *derp_map_url, char **out, char **error); + +/** + * Generate a private/public node-key pair and a random pre-shared key locally. + * + * *out receives JSON with private_key, public_key, preshared_key in Tailcat's + * text encodings. Persist both private_key and preshared_key to preserve a + * server's identity/capability across restarts. + * + * The JSON contains secrets; store securely and free with tc_free. No network + * access is performed. + */ +int32_t tc_key_generate(char **out, char **error); + +/* END CFFI */ + +#ifdef __cplusplus +} +#endif +#endif diff --git a/cmd/libtailcat/testdata/smoke.c b/cmd/libtailcat/testdata/smoke.c new file mode 100644 index 000000000..382ac2b0f --- /dev/null +++ b/cmd/libtailcat/testdata/smoke.c @@ -0,0 +1,60 @@ +/* Copyright (c) Tailscale Inc & contributors + * SPDX-License-Identifier: BSD-3-Clause */ +#include "tailcat.h" +#include +#include + +int main(void) { + assert(tc_abi_version() == 1); + char *error = NULL; + char *keys = NULL; + assert(tc_key_generate(&keys, &error) == TC_OK); + assert(error == NULL && strstr(keys, "private_key") != NULL); + tc_free(keys); + + tc_handle server = 0; + tc_token token = TC_NO_CANCEL; + assert(tc_server_new("{}", &server, &error) == TC_OK); + assert(server != 0 && error == NULL); + assert(TC_NO_CANCEL == 0); + assert(tc_drain(server, TC_NO_CANCEL, &error) == TC_OK); + assert(error == NULL); + assert(tc_token_cancel(TC_NO_CANCEL, &error) == TC_CLOSED); + assert(error != NULL); + tc_free(error); + assert(tc_close(TC_NO_CANCEL, &error) == TC_CLOSED); + assert(error != NULL); + tc_free(error); + /* Rejecting the sentinel must not affect the server. */ + assert(tc_drain(server, TC_NO_CANCEL, &error) == TC_OK); + assert(error == NULL); + assert(tc_token_new(-1, &token, &error) == TC_OK); + assert(tc_drain(server, token, &error) == TC_OK); + assert(tc_token_cancel(token, &error) == TC_OK); + assert(tc_close(token, &error) == TC_OK); + assert(tc_close(server, &error) == TC_OK); + assert(tc_close(server, &error) == TC_CLOSED); + assert(error != NULL); + tc_free(error); + + tc_handle client = 42; + assert(tc_client_new("{\"address\":\"secret\"}", &client, &error) == TC_INVALID_ARGUMENT); + assert(client == 0 && strstr(error, "secret") == NULL); + tc_free(error); + + int32_t ping_ms = -1; + assert(tc_client_ping(0, TC_NO_CANCEL, &ping_ms, &error) == TC_CLOSED); + assert(ping_ms == 0 && error != NULL); + tc_free(error); + assert(tc_client_ping(0, TC_NO_CANCEL, NULL, &error) == TC_INVALID_ARGUMENT); + tc_free(error); + + char previous[] = "previous result"; + char *disco_json = previous; + assert(tc_client_disco_ping(0, TC_NO_CANCEL, &disco_json, &error) == TC_CLOSED); + assert(disco_json == NULL && error != NULL); + tc_free(error); + assert(tc_client_disco_ping(0, TC_NO_CANCEL, NULL, &error) == TC_INVALID_ARGUMENT); + tc_free(error); + return 0; +} diff --git a/internal/capi/api.go b/internal/capi/api.go new file mode 100644 index 000000000..6ba64eb15 --- /dev/null +++ b/internal/capi/api.go @@ -0,0 +1,585 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package capi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "runtime/debug" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/tailscale/tailcat" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + "tailscale.com/types/logger" +) + +const ABIVersion = 1 +const TCP = 1 +const UDP = 2 + +type ClientConfig struct { + Address string `json:"address"` + Key string `json:"key,omitempty"` + DERPMapURL string `json:"derp_map_url,omitempty"` +} + +type ServerConfig struct { + Key string `json:"key,omitempty"` + PresharedKey string `json:"preshared_key,omitempty"` + Region *tailcfg.DERPRegion `json:"region,omitempty"` + RegionID tailcfg.DERPRegionID `json:"region_id,omitempty"` + DERPMapURL string `json:"derp_map_url,omitempty"` + AllowedClients []string `json:"allowed_clients,omitempty"` + UDPIdleTimeout float64 `json:"udp_idle_timeout,omitempty"` +} + +// ready records a successful first operation; DrainTCP is only valid afterwards. +type client struct { + client *tailcat.Client + ready atomic.Bool +} + +type server struct { + server *tailcat.Server + gate gate + started bool +} + +type listener struct { + ln tailcat.ContextListener + network int +} + +type connection struct { + conn net.Conn + readGate gate + writeGate gate + network int + reads chan readResult + stopped chan struct{} + pumpDone chan struct{} + closeOnce sync.Once + pending []byte // guarded by readGate + pendingBuf []byte // backing buffer of pending + readError error // guarded by readGate +} + +type readResult struct { + buf []byte + data []byte + err error +} + +const readBufferSize = 32 * 1024 + +var readBuffers = sync.Pool{New: func() any { + b := make([]byte, readBufferSize) + return &b +}} + +func (c *connection) take(result readResult, ok bool) { + c.pending, c.pendingBuf, c.readError = result.data, result.buf, result.err + if !ok { + c.readError = io.EOF + } + c.release() +} + +func (c *connection) release() { + if len(c.pending) == 0 && c.pendingBuf != nil { + b := c.pendingBuf + c.pending, c.pendingBuf = nil, nil + readBuffers.Put(&b) + } +} + +func newConnection(conn net.Conn, network int) *connection { + c := &connection{conn: conn, readGate: newGate(), writeGate: newGate(), network: network, + stopped: make(chan struct{}), pumpDone: make(chan struct{})} + if network == TCP { + c.reads = make(chan readResult, 1) + go c.receive() + } else { + close(c.pumpDone) + } + return c +} + +// receive owns all TCP reads and has bounded backpressure. gVisor checks expired +// deadlines before readable state, so setting an immediate deadline is not a +// reliable EOF probe. This queue provides non-consuming readiness without an OS +// descriptor, and never retains a caller's memory across an ABI call. +func (c *connection) receive() { + defer close(c.pumpDone) + defer close(c.reads) + for { + b := *readBuffers.Get().(*[]byte) + n, err := c.conn.Read(b) + if n == 0 && err == nil { + readBuffers.Put(&b) + continue + } + select { + case c.reads <- readResult{b, b[:n], err}: + case <-c.stopped: + return + } + if err != nil { + return + } + } +} + +func (c *connection) close() { + c.closeOnce.Do(func() { + close(c.stopped) + c.conn.Close() + }) +} + +func (c *connection) readTCP(ctx context.Context, data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } + if len(c.pending) == 0 && c.readError == nil { + select { + case result, ok := <-c.reads: + c.take(result, ok) + case <-ctx.Done(): + return 0, ctx.Err() + } + } + if len(c.pending) > 0 { + n := copy(data, c.pending) + c.pending = c.pending[n:] + c.release() + return n, nil + } + return 0, c.readError +} + +// Decoder errors may quote the input, which can contain secrets; only the +// unknown-field error, which names just the field, is passed through. +func decode(config string, target any) error { + d := json.NewDecoder(strings.NewReader(config)) + d.DisallowUnknownFields() + if err := d.Decode(target); err != nil { + if strings.HasPrefix(err.Error(), "json: unknown field ") { + return fmt.Errorf("%w: %v", ErrArgument, err) + } + return fmt.Errorf("%w: invalid configuration", ErrArgument) + } + if err := d.Decode(new(any)); err != io.EOF { + return fmt.Errorf("%w: trailing configuration data", ErrArgument) + } + return nil +} + +func nodeKey(text string) (key.NodePrivate, error) { + if text == "" { + return key.NewNode(), nil + } + var k key.NodePrivate + if err := k.UnmarshalText([]byte(text)); err != nil || k.IsZero() { + return k, fmt.Errorf("%w: invalid private key", ErrArgument) + } + return k, nil +} + +func NewClient(config string) (Handle, error) { + var cfg ClientConfig + if err := decode(config, &cfg); err != nil { + return 0, err + } + ci, err := tailcat.ParseAddr(tailcat.Addr(cfg.Address)) + if err != nil || ci.ServerDiscoPublic.IsZero() { + return 0, fmt.Errorf("%w: invalid or unsupported tailcat address", ErrArgument) + } + k, err := nodeKey(cfg.Key) + if err != nil { + return 0, err + } + return register(&client{client: &tailcat.Client{Server: tailcat.Addr(cfg.Address), Key: k, DERPMapURL: cfg.DERPMapURL, Logf: logger.Discard}}, 0) +} + +func NewServer(config string) (Handle, error) { + var cfg ServerConfig + if err := decode(config, &cfg); err != nil { + return 0, err + } + k, err := nodeKey(cfg.Key) + if err != nil { + return 0, err + } + psk := tailcat.NewPresharedKey() + if cfg.PresharedKey != "" { + if err := psk.UnmarshalText([]byte(cfg.PresharedKey)); err != nil || psk.IsZero() { + return 0, fmt.Errorf("%w: invalid pre-shared key", ErrArgument) + } + } + if cfg.UDPIdleTimeout < 0 || cfg.UDPIdleTimeout > float64(int64(^uint64(0)>>1))/float64(time.Second) { + return 0, fmt.Errorf("%w: invalid UDP idle timeout", ErrArgument) + } + s := &tailcat.Server{Key: k, PresharedKey: psk, Region: cfg.Region, RegionID: cfg.RegionID, DERPMapURL: cfg.DERPMapURL, Logf: logger.Discard, UDPIdleTimeout: time.Duration(cfg.UDPIdleTimeout * float64(time.Second))} + for _, text := range cfg.AllowedClients { + var pub key.NodePublic + if err := pub.UnmarshalText([]byte(text)); err != nil || pub.IsZero() { + return 0, fmt.Errorf("%w: invalid allowed client key", ErrArgument) + } + s.AllowedClients = append(s.AllowedClients, pub) + } + return register(&server{server: s, gate: newGate()}, 0) +} + +func publishConnection(ctx context.Context, parent Handle, conn net.Conn, network int) (Handle, error) { + if err := ctx.Err(); err != nil { + conn.Close() + return 0, err + } + c := newConnection(conn, network) + h, err := register(c, parent) + if err != nil { + c.close() + <-c.pumpDone + } + return h, err +} + +func Dial(id, token Handle, port uint16, network int) (result Handle, err error) { + if port == 0 || (network != TCP && network != UDP) { + return 0, ErrArgument + } + err = use[*client](id, token, func(ctx context.Context, e *entry, c *client) error { + var conn net.Conn + var err error + if network == TCP { + conn, err = c.client.DialTCPPort(ctx, port) + } else { + conn, err = c.client.DialUDPPort(ctx, port) + } + if err != nil { + return err + } + c.ready.Store(true) + result, err = publishConnection(ctx, e.id, conn, network) + return err + }) + return +} + +// startLocked starts the server once; the caller holds s.gate. +func (s *server) startLocked(ctx context.Context) error { + if s.started { + return nil + } + if err := s.server.StartContext(ctx); err != nil { + return err + } + s.started = true + return nil +} + +func Start(id, token Handle) error { + return use[*server](id, token, func(ctx context.Context, e *entry, s *server) error { + if err := s.gate.lock(ctx); err != nil { + return err + } + defer s.gate.unlock() + return s.startLocked(ctx) + }) +} + +func Listen(id, token Handle, port uint16, network int) (result Handle, err error) { + if network != TCP && network != UDP { + return 0, ErrArgument + } + err = use[*server](id, token, func(ctx context.Context, e *entry, s *server) error { + if err := s.gate.lock(ctx); err != nil { + return err + } + defer s.gate.unlock() + if err := s.startLocked(ctx); err != nil { + return err + } + n := "tcp" + if network == UDP { + n = "udp" + } + ln, err := s.server.Listen(ctx, n, fmt.Sprintf(":%d", port)) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + ln.Close() + return err + } + result, err = register(&listener{ln: ln.(tailcat.ContextListener), network: network}, e.id) + if err != nil { + ln.Close() + } + return err + }) + return +} + +func Accept(id, token Handle) (result Handle, err error) { + err = use[*listener](id, token, func(ctx context.Context, e *entry, l *listener) error { + conn, err := l.ln.AcceptContext(ctx) + if err != nil { + return err + } + result, err = publishConnection(ctx, e.parent, conn, l.network) + return err + }) + return +} + +// withDeadline serializes operations in one direction, including waiting for the +// gate in their deadlines. The cancellation callback is joined before clearing +// the deadline, so it cannot poison a later operation on the same connection. +func withDeadline(ctx context.Context, g gate, set func(time.Time) error, fn func() error) error { + if err := g.lock(ctx); err != nil { + return err + } + defer g.unlock() + deadline, _ := ctx.Deadline() + if err := set(deadline); err != nil { + return err + } + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { set(time.Now()); close(done) }) + err := fn() + if !stop() { + <-done + } + set(time.Time{}) + if err != nil && ctx.Err() != nil { + return ctx.Err() + } + return err +} + +func Read(id, token Handle, data []byte) (n int, err error) { + err = use[*connection](id, token, func(ctx context.Context, e *entry, c *connection) error { + if c.network == TCP { + if err := c.readGate.lock(ctx); err != nil { + return err + } + defer c.readGate.unlock() + var err error + n, err = c.readTCP(ctx, data) + return err + } + return withDeadline(ctx, c.readGate, c.conn.SetReadDeadline, func() error { + var err error + n, err = c.conn.Read(data) + return err + }) + }) + return +} + +func Write(id, token Handle, data []byte) (n int, err error) { + err = use[*connection](id, token, func(ctx context.Context, e *entry, c *connection) error { + if c.network == UDP && len(data) > tailcat.MaxUDPPayload { + return fmt.Errorf("%w: datagram exceeds MaxUDPPayload", ErrArgument) + } + return withDeadline(ctx, c.writeGate, c.conn.SetWriteDeadline, func() error { + var err error + n, err = c.conn.Write(data) + return err + }) + }) + return +} + +func CloseWrite(id, token Handle) error { + return use[*connection](id, token, func(ctx context.Context, e *entry, c *connection) error { + if err := c.writeGate.lock(ctx); err != nil { + return err + } + defer c.writeGate.unlock() + cw, ok := c.conn.(interface{ CloseWrite() error }) + if !ok { + return fmt.Errorf("%w: half-close requires TCP", ErrArgument) + } + return cw.CloseWrite() + }) +} + +// Readable performs a non-consuming probe for connection-pool expiry. It never +// waits behind a concurrent reader. For idle TCP, data or EOF both expire HTTP/1. +func Readable(id Handle) (bool, error) { + e, err := borrow(id) + if err != nil { + return true, err + } + defer e.active.Done() + c, ok := e.value.(*connection) + if !ok || c.network != TCP { + return false, ErrType + } + select { + case c.readGate <- struct{}{}: + default: + return false, nil + } + defer c.readGate.unlock() + if len(c.pending) > 0 || c.readError != nil { + return true, nil + } + select { + case result, ok := <-c.reads: + c.take(result, ok) + return true, nil + default: + return false, nil + } +} + +func Info(id, token Handle) (out string, err error) { + err = use[any](id, token, func(ctx context.Context, e *entry, v any) error { + var info any + switch v := v.(type) { + case *client: + info = map[string]any{"public_key": v.client.PublicKey().String()} + case *server: + if err := v.gate.lock(ctx); err != nil { + return err + } + defer v.gate.unlock() + if !v.started { + return fmt.Errorf("%w: server has not started", ErrArgument) + } + info = map[string]any{"address": v.server.TailcatAddr(), "local_address": v.server.Addr().String(), "public_key": v.server.Key.Public().String()} + case *listener: + info = map[string]any{"local_address": v.ln.Addr().String(), "network": v.network} + case *connection: + info = map[string]any{"local_address": v.conn.LocalAddr().String(), "remote_address": v.conn.RemoteAddr().String(), "network": v.network} + default: + return ErrType + } + b, err := json.Marshal(info) + out = string(b) + return err + }) + return +} + +func Ping(id, token Handle) (pingMS int32, err error) { + err = use[*client](id, token, func(ctx context.Context, e *entry, c *client) error { + r, err := c.client.Ping(ctx) + if err != nil { + return err + } + c.ready.Store(true) + pingMS = int32(r.Latency.Milliseconds()) + return nil + }) + return +} + +func DiscoPing(id, token Handle) (out string, err error) { + err = use[*client](id, token, func(ctx context.Context, e *entry, c *client) error { + r, err := c.client.DiscoPing(ctx) + if err != nil { + return err + } + c.ready.Store(true) + value := map[string]any{"latency": r.LatencySeconds, "endpoint": r.Endpoint, "derp_region_id": r.DERPRegionID, "derp_region_code": r.DERPRegionCode} + b, err := json.Marshal(value) + out = string(b) + return err + }) + return +} + +func AllowClient(id, token Handle, text string) error { + var pub key.NodePublic + if err := pub.UnmarshalText([]byte(text)); err != nil || pub.IsZero() { + return ErrArgument + } + return use[*server](id, token, func(ctx context.Context, e *entry, s *server) error { + if err := s.gate.lock(ctx); err != nil { + return err + } + defer s.gate.unlock() + if !s.started { + s.server.AllowedClients = append(s.server.AllowedClients, pub) + } else { + s.server.AddAllowedClient(pub) + } + return nil + }) +} + +func Drain(id, token Handle) error { + return use[any](id, token, func(ctx context.Context, e *entry, v any) error { + switch v := v.(type) { + case *client: + if !v.ready.Load() { + return nil + } + return v.client.DrainTCP(ctx) + case *server: + if err := v.gate.lock(ctx); err != nil { + return err + } + defer v.gate.unlock() + if !v.started { + return nil + } + return v.server.DrainTCP(ctx) + default: + return ErrType + } + }) +} + +func ParseAddress(text string) (string, error) { + ci, err := tailcat.ParseAddr(tailcat.Addr(text)) + if err != nil { + return "", fmt.Errorf("%w: invalid tailcat address", ErrArgument) + } + // Deliberately omit the pre-shared key from diagnostic metadata. + b, err := json.Marshal(map[string]any{"public_key": ci.ServerPublic.String(), "disco_public_key": ci.ServerDiscoPublic.String(), "has_preshared_key": !ci.PresharedKey.IsZero(), "region_id": ci.RegionID, "regions": ci.Region}) + return string(b), err +} + +func ResolveAddress(token Handle, text, mapURL string) (string, error) { + if _, err := ParseAddress(text); err != nil { + return "", err + } + ctx, err := tokenContext(token) + if err != nil { + return "", err + } + var opts []any + if mapURL != "" { + opts = append(opts, tailcat.DERPMapURL(mapURL)) + } + a, err := tailcat.Addr(text).Resolve(ctx, opts...) + return string(a), err +} + +func GenerateKey() (string, error) { + k := key.NewNode() + psk, _ := tailcat.NewPresharedKey().MarshalText() + text, _ := k.MarshalText() + b, err := json.Marshal(map[string]string{"private_key": string(text), "public_key": k.Public().String(), "preshared_key": string(psk)}) + return string(b), err +} + +func BuildInfo() string { + info, _ := debug.ReadBuildInfo() + b, _ := json.Marshal(map[string]any{"abi_version": ABIVersion, "go": info}) + return string(b) +} diff --git a/internal/capi/api_test.go b/internal/capi/api_test.go new file mode 100644 index 000000000..2a68633fc --- /dev/null +++ b/internal/capi/api_test.go @@ -0,0 +1,138 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package capi + +import ( + "context" + "errors" + "net" + "runtime" + "testing" + "time" +) + +func testToken(t *testing.T, timeout time.Duration) Handle { + t.Helper() + h, err := NewToken(int64(timeout)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Close(h) }) + return h +} + +func testPipe(t *testing.T) (Handle, net.Conn) { + t.Helper() + a, b := net.Pipe() + h, err := register(newConnection(a, TCP), 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Close(h); b.Close() }) + return h, b +} + +func TestCancelledReadDoesNotPoisonNextRead(t *testing.T) { + h, remote := testPipe(t) + token := testToken(t, -1) + done := make(chan error, 1) + go func() { _, err := Read(h, token, make([]byte, 8)); done <- err }() + if err := Cancel(token); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("read: %v", err) + } + case <-time.After(time.Second): + t.Fatal("cancel did not unblock read") + } + go remote.Write([]byte("ok")) + buf := make([]byte, 8) + n, err := Read(h, testToken(t, time.Second), buf) + if err != nil || string(buf[:n]) != "ok" { + t.Fatalf("next read = %q, %v", buf[:n], err) + } +} + +func TestCloseUnblocksReadAndRejectsStaleHandle(t *testing.T) { + h, _ := testPipe(t) + done := make(chan error, 1) + go func() { _, err := Read(h, testToken(t, -1), make([]byte, 8)); done <- err }() + if err := Close(h); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if ErrorCode(err) != Closed { + t.Fatalf("read: %v", err) + } + case <-time.After(time.Second): + t.Fatal("close did not unblock read") + } + if _, err := Write(h, testToken(t, time.Second), []byte("x")); !errors.Is(err, ErrHandle) { + t.Fatalf("write: %v", err) + } +} + +func TestDeadlineIncludesWaitingForAnotherRead(t *testing.T) { + h, _ := testPipe(t) + e, _ := borrow(h) + c := e.value.(*connection) + e.active.Done() + if err := c.readGate.lock(context.Background()); err != nil { + t.Fatal(err) + } + defer c.readGate.unlock() + _, err := Read(h, testToken(t, 10*time.Millisecond), make([]byte, 1)) + if ErrorCode(err) != Timeout { + t.Fatalf("read = %v", err) + } +} + +func TestReadabilityDoesNotConsumePayload(t *testing.T) { + h, remote := testPipe(t) + go remote.Write([]byte("hello")) + // The probe may race with delivery but must never discard a byte. + Readable(h) + buf := make([]byte, 5) + n, err := Read(h, testToken(t, time.Second), buf) + if err != nil || string(buf[:n]) != "hello" { + t.Fatalf("read = %q, %v", buf[:n], err) + } +} + +func TestReadabilityReportsPeerEOF(t *testing.T) { + h, remote := testPipe(t) + remote.Close() + deadline := time.Now().Add(time.Second) + for { + ready, err := Readable(h) + if err != nil { + t.Fatal(err) + } + if ready { + break + } + if time.Now().After(deadline) { + t.Fatal("peer EOF never became readable") + } + runtime.Gosched() + } + if n, err := Read(h, testToken(t, time.Second), make([]byte, 1)); n != 0 || ErrorCode(err) != EOF { + t.Fatalf("read after EOF probe: %d, %v", n, err) + } +} + +func TestInvalidConfigurationDoesNotEchoSecrets(t *testing.T) { + _, err := NewClient(`{"address":"secret-address"}`) + if ErrorCode(err) != InvalidArgument { + t.Fatalf("new client: %v", err) + } + _, err = NewServer(`{"key":"secret-key"}`) + if err == nil || err.Error() != "invalid argument: invalid private key" { + t.Fatalf("new server: %v", err) + } +} diff --git a/internal/capi/e2e_test.go b/internal/capi/e2e_test.go new file mode 100644 index 000000000..348530aa4 --- /dev/null +++ b/internal/capi/e2e_test.go @@ -0,0 +1,272 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package capi + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "testing" + "time" + + "tailscale.com/tstest/integration" + "tailscale.com/types/logger" +) + +// e2e runs a server and client against a local relay. +type e2e struct { + t *testing.T + server Handle + client Handle +} + +func newE2E(t *testing.T) *e2e { + t.Helper() + dm := integration.RunDERPAndSTUN(t, logger.Discard, "127.0.0.1") + region, err := json.Marshal(dm.Regions[1]) + if err != nil { + t.Fatal(err) + } + server, err := NewServer(fmt.Sprintf(`{"region":%s}`, region)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Close(server) }) + x := &e2e{t: t, server: server} + if err := Start(server, x.token()); err != nil { + t.Fatalf("start: %v", err) + } + info := x.info(server) + client, err := NewClient(fmt.Sprintf(`{"address":%q,"derp_map_url":"none"}`, info["address"])) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Close(client) }) + x.client = client + return x +} + +func (x *e2e) token() Handle { return testToken(x.t, 30*time.Second) } + +func (x *e2e) info(h Handle) map[string]any { + x.t.Helper() + text, err := Info(h, x.token()) + if err != nil { + x.t.Fatalf("info: %v", err) + } + var m map[string]any + if err := json.Unmarshal([]byte(text), &m); err != nil { + x.t.Fatal(err) + } + return m +} + +func (x *e2e) port(h Handle) uint16 { + x.t.Helper() + _, p, err := net.SplitHostPort(x.info(h)["local_address"].(string)) + if err != nil { + x.t.Fatal(err) + } + n, err := strconv.Atoi(p) + if err != nil { + x.t.Fatal(err) + } + return uint16(n) +} + +// connect listens on a fresh port, dials it, and accepts the connection. +func (x *e2e) connect(network int) (ln, dialed, accepted Handle) { + x.t.Helper() + ln, err := Listen(x.server, x.token(), 0, network) + if err != nil { + x.t.Fatalf("listen: %v", err) + } + dialed, err = Dial(x.client, x.token(), x.port(ln), network) + if err != nil { + x.t.Fatalf("dial: %v", err) + } + if network == UDP { + if _, err := Write(dialed, x.token(), []byte("hello")); err != nil { + x.t.Fatalf("first datagram: %v", err) + } + } + accepted, err = Accept(ln, x.token()) + if err != nil { + x.t.Fatalf("accept: %v", err) + } + return ln, dialed, accepted +} + +func readAll(t *testing.T, h, token Handle) string { + t.Helper() + var sb strings.Builder + buf := make([]byte, 1024) + for { + n, err := Read(h, token, buf) + sb.Write(buf[:n]) + if errors.Is(err, io.EOF) { + return sb.String() + } + if err != nil { + t.Fatalf("read: %v", err) + } + } +} + +func TestE2ETCPRoundTripAndHalfClose(t *testing.T) { + x := newE2E(t) + ln, dialed, accepted := x.connect(TCP) + if n, err := Write(dialed, x.token(), []byte("ping")); err != nil || n != 4 { + t.Fatalf("write: %d, %v", n, err) + } + if err := CloseWrite(dialed, x.token()); err != nil { + t.Fatalf("close write: %v", err) + } + if got := readAll(t, accepted, x.token()); got != "ping" { + t.Fatalf("server read %q", got) + } + if ready, err := Readable(accepted); err != nil || !ready { + t.Fatalf("readable after EOF = %v, %v", ready, err) + } + if n, err := Write(accepted, x.token(), []byte("pong")); err != nil || n != 4 { + t.Fatalf("server write: %d, %v", n, err) + } + if err := Close(accepted); err != nil { + t.Fatalf("close accepted: %v", err) + } + if got := readAll(t, dialed, x.token()); got != "pong" { + t.Fatalf("client read %q", got) + } + if err := Close(dialed); err != nil { + t.Fatal(err) + } + if err := Drain(x.client, x.token()); err != nil { + t.Fatalf("drain client: %v", err) + } + if err := Close(ln); err != nil { + t.Fatal(err) + } + if _, err := Accept(ln, x.token()); !errors.Is(err, ErrHandle) { + t.Fatalf("accept on closed listener: %v", err) + } +} + +func TestE2EUDPRoundTripAndDeadline(t *testing.T) { + x := newE2E(t) + _, dialed, accepted := x.connect(UDP) + buf := make([]byte, 64) + n, err := Read(accepted, x.token(), buf) + if err != nil || string(buf[:n]) != "hello" { + t.Fatalf("server read %q, %v", buf[:n], err) + } + if _, err := Write(accepted, x.token(), []byte("world")); err != nil { + t.Fatalf("server write: %v", err) + } + n, err = Read(dialed, x.token(), buf) + if err != nil || string(buf[:n]) != "world" { + t.Fatalf("client read %q, %v", buf[:n], err) + } + if _, err := Read(dialed, testToken(t, 50*time.Millisecond), buf); ErrorCode(err) != Timeout { + t.Fatalf("idle UDP read = %v, want timeout", err) + } + if _, err := Write(accepted, x.token(), []byte("again")); err != nil { + t.Fatalf("server write: %v", err) + } + n, err = Read(dialed, x.token(), buf) + if err != nil || string(buf[:n]) != "again" { + t.Fatalf("read after timeout = %q, %v", buf[:n], err) + } + if err := CloseWrite(dialed, x.token()); ErrorCode(err) != InvalidArgument { + t.Fatalf("UDP half-close = %v", err) + } + if _, err := Write(dialed, x.token(), make([]byte, 1233)); ErrorCode(err) != InvalidArgument { + t.Fatalf("oversized datagram = %v", err) + } +} + +func TestE2EClientPingAndInfo(t *testing.T) { + x := newE2E(t) + if err := Drain(x.client, x.token()); err != nil { + t.Fatalf("drain before start: %v", err) + } + if ms, err := Ping(x.client, x.token()); err != nil || ms < 0 { + t.Fatalf("ping = %d, %v", ms, err) + } + if _, ok := x.info(x.client)["public_key"]; !ok { + t.Fatal("client info lacks public_key") + } + if err := Drain(x.client, x.token()); err != nil { + t.Fatalf("drain after ping: %v", err) + } +} + +func TestE2EListenFailureKeepsServerStarted(t *testing.T) { + dm := integration.RunDERPAndSTUN(t, logger.Discard, "127.0.0.1") + region, err := json.Marshal(dm.Regions[1]) + if err != nil { + t.Fatal(err) + } + server, err := NewServer(fmt.Sprintf(`{"region":%s}`, region)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Close(server) }) + token := testToken(t, 30*time.Second) + ln, err := Listen(server, token, 0, TCP) + if err != nil { + t.Fatalf("listen: %v", err) + } + x := &e2e{t: t, server: server} + port := x.port(ln) + if _, err := Listen(server, token, port, TCP); err == nil { + t.Fatal("duplicate listen succeeded") + } + if err := Start(server, token); err != nil { + t.Fatalf("start after listen: %v", err) + } + if _, ok := x.info(server)["address"]; !ok { + t.Fatal("server info lacks address") + } +} + +func TestE2ECloseServerRetiresTree(t *testing.T) { + x := newE2E(t) + ln, dialed, accepted := x.connect(TCP) + blocked := make(chan error, 2) + go func() { _, err := Accept(ln, testToken(t, -1)); blocked <- err }() + go func() { _, err := Read(accepted, testToken(t, -1), make([]byte, 1)); blocked <- err }() + time.Sleep(100 * time.Millisecond) + done := make(chan error, 1) + go func() { done <- Close(x.server) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("close server: %v", err) + } + case <-time.After(30 * time.Second): + t.Fatal("closing the server did not return") + } + for range 2 { + select { + case err := <-blocked: + if ErrorCode(err) != Closed { + t.Fatalf("blocked call after server close: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server close did not unblock its resource tree") + } + } + for name, h := range map[string]Handle{"listener": ln, "accepted": accepted} { + if _, err := Info(h, NoCancel); !errors.Is(err, ErrHandle) { + t.Fatalf("%s survived server close: %v", name, err) + } + } + if _, err := Read(dialed, x.token(), make([]byte, 1)); err == nil { + t.Fatal("read from dead peer succeeded") + } +} diff --git a/internal/capi/registry.go b/internal/capi/registry.go new file mode 100644 index 000000000..3c0421fca --- /dev/null +++ b/internal/capi/registry.go @@ -0,0 +1,257 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package capi implements the resource and cancellation contracts of libtailcat. +// It is independent of cgo so its lifetime rules can be tested with the race detector. +package capi + +import ( + "context" + "errors" + "io" + "net" + "sync" + "time" +) + +type Handle uint64 + +// NoCancel supplies no caller deadline or cancellation token. It is accepted +// only as a token argument, never as a registered resource handle. +const NoCancel Handle = 0 + +var ( + ErrHandle = errors.New("invalid or closed tailcat handle") + ErrType = errors.New("incorrect tailcat handle type") + ErrArgument = errors.New("invalid argument") +) + +const ( + OK = iota + InvalidArgument + Closed + Timeout + Cancelled + Failure + EOF +) + +func ErrorCode(err error) int { + switch { + case err == nil: + return OK + case errors.Is(err, io.EOF): + return EOF + case errors.Is(err, ErrArgument), errors.Is(err, ErrType): + return InvalidArgument + case errors.Is(err, ErrHandle), errors.Is(err, net.ErrClosed): + return Closed + case errors.Is(err, context.DeadlineExceeded): + return Timeout + case errors.Is(err, context.Canceled): + return Cancelled + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return Timeout + } + return Failure +} + +type entry struct { + id Handle + parent Handle + value any + ctx context.Context + cancel context.CancelFunc + active sync.WaitGroup + children map[Handle]bool +} + +var registry = struct { + sync.Mutex + next Handle + entries map[Handle]*entry +}{entries: make(map[Handle]*entry)} + +func register(value any, parent Handle) (Handle, error) { + registry.Lock() + defer registry.Unlock() + if parent != 0 && registry.entries[parent] == nil { + return 0, ErrHandle + } + registry.next++ + id := registry.next + ctx, cancel := context.WithCancel(context.Background()) + e := &entry{id: id, parent: parent, value: value, ctx: ctx, cancel: cancel, children: make(map[Handle]bool)} + registry.entries[id] = e + if parent != 0 { + registry.entries[parent].children[id] = true + } + return id, nil +} + +// borrow increments active while holding the same lock used to retire handles. +// Close can therefore safely wait for all users after removing an entry. +func borrow(id Handle) (*entry, error) { + registry.Lock() + defer registry.Unlock() + e := registry.entries[id] + if e == nil { + return nil, ErrHandle + } + e.active.Add(1) + return e, nil +} + +type cancellationToken struct { + ctx context.Context + cancel context.CancelFunc +} + +// NewToken starts the deadline immediately from now. +// -1 means no deadline; zero means already expired. Other negative values fail. +func NewToken(timeoutNS int64) (Handle, error) { + if timeoutNS < -1 { + return 0, ErrArgument + } + ctx := context.Background() + var cancel context.CancelFunc + if timeoutNS == -1 { + ctx, cancel = context.WithCancel(ctx) + } else { + ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutNS)) + } + return register(&cancellationToken{ctx, cancel}, 0) +} + +func Cancel(id Handle) error { + e, err := borrow(id) + if err != nil { + return err + } + defer e.active.Done() + token, ok := e.value.(*cancellationToken) + if !ok { + return ErrType + } + token.cancel() + return nil +} + +func tokenContext(id Handle) (context.Context, error) { + if id == NoCancel { + return context.Background(), nil + } + e, err := borrow(id) + if err != nil { + return nil, err + } + defer e.active.Done() + token, ok := e.value.(*cancellationToken) + if !ok { + return nil, ErrType + } + return token.ctx, nil +} + +func use[T any](id, tokenID Handle, fn func(context.Context, *entry, T) error) error { + e, err := borrow(id) + if err != nil { + return err + } + defer e.active.Done() + value, ok := e.value.(T) + if !ok { + return ErrType + } + ctx, err := tokenContext(tokenID) + if err != nil { + return err + } + ctx, cancel := context.WithCancel(ctx) + stop := context.AfterFunc(e.ctx, cancel) + defer func() { stop(); cancel() }() + if e.ctx.Err() != nil { + return net.ErrClosed + } + if err := ctx.Err(); err != nil { + return err + } + err = fn(ctx, e, value) + if err != nil && e.ctx.Err() != nil { + return net.ErrClosed + } + return err +} + +// Close retires the complete resource tree before cancelling any work. Accepted +// connections belong to the server, not the listener. No blocking work holds the +// registry lock. Go references borrowed by an active call remain alive throughout. +func Close(id Handle) error { + registry.Lock() + var retired []*entry + var retire func(Handle) + retire = func(h Handle) { + e := registry.entries[h] + if e == nil { + return + } + delete(registry.entries, h) + if p := registry.entries[e.parent]; p != nil { + delete(p.children, h) + } + for child := range e.children { + retire(child) + } + retired = append(retired, e) + } + retire(id) + registry.Unlock() + if len(retired) == 0 { + return ErrHandle + } + for _, e := range retired { + e.cancel() + switch v := e.value.(type) { + case *cancellationToken: + v.cancel() + case *connection: + v.close() + case *listener: + v.ln.Close() + } + } + var result error + for _, e := range retired { + e.active.Wait() + switch v := e.value.(type) { + case *connection: + <-v.pumpDone + case *client: + result = errors.Join(result, v.client.Close()) + case *server: + result = errors.Join(result, v.server.Close()) + } + } + return result +} + +type gate chan struct{} + +func newGate() gate { return make(gate, 1) } + +func (g gate) lock(ctx context.Context) error { + select { + case g <- struct{}{}: + if err := ctx.Err(); err != nil { + g.unlock() + return err + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (g gate) unlock() { <-g } diff --git a/internal/capi/token_test.go b/internal/capi/token_test.go new file mode 100644 index 000000000..8ddd4fae0 --- /dev/null +++ b/internal/capi/token_test.go @@ -0,0 +1,140 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package capi + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestNoCancelHasNoDeadlineOrCancellation(t *testing.T) { + ctx, err := tokenContext(NoCancel) + if err != nil { + t.Fatal(err) + } + if _, ok := ctx.Deadline(); ok || ctx.Done() != nil || ctx.Err() != nil { + t.Fatal("no-cancel context has a deadline or cancellation signal") + } +} + +func TestNoCancelSupportsReadAndWrite(t *testing.T) { + h, remote := testPipe(t) + written := make(chan error, 1) + go func() { _, err := remote.Write([]byte("hello")); written <- err }() + buffer := make([]byte, 5) + if n, err := Read(h, NoCancel, buffer); err != nil || string(buffer[:n]) != "hello" { + t.Fatalf("read = %q, %v", buffer[:n], err) + } + if err := <-written; err != nil { + t.Fatal(err) + } + read := make(chan string, 1) + go func() { + b := make([]byte, 5) + n, _ := remote.Read(b) + read <- string(b[:n]) + }() + if n, err := Write(h, NoCancel, []byte("world")); err != nil || n != 5 { + t.Fatalf("write = %d, %v", n, err) + } + if got := <-read; got != "world" { + t.Fatalf("peer received %q", got) + } +} + +func TestNoCancelIsNotAResourceHandle(t *testing.T) { + for name, fn := range map[string]func() error{ + "cancel": func() error { return Cancel(NoCancel) }, + "close": func() error { return Close(NoCancel) }, + "info": func() error { _, err := Info(NoCancel, NoCancel); return err }, + } { + t.Run(name, func(t *testing.T) { + if err := fn(); ErrorCode(err) != Closed { + t.Fatalf("got %v, want TC_CLOSED", err) + } + }) + } +} + +func TestNoCancelStillObservesResourceClosure(t *testing.T) { + for _, closeParent := range []bool{false, true} { + name := "resource" + if closeParent { + name = "parent" + } + t.Run(name, func(t *testing.T) { + parent, err := register(struct{}{}, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { Close(parent) }) + resource, err := register(struct{}{}, parent) + if err != nil { + t.Fatal(err) + } + entered := make(chan context.Context, 1) + result := make(chan error, 1) + go func() { + result <- use[struct{}](resource, NoCancel, func(ctx context.Context, _ *entry, _ struct{}) error { + entered <- ctx + <-ctx.Done() + return ctx.Err() + }) + }() + var ctx context.Context + select { + case ctx = <-entered: + case err := <-result: + t.Fatalf("call returned before resource closure: %v", err) + case <-time.After(time.Second): + t.Fatal("call never started") + } + if err := Cancel(NoCancel); !errors.Is(err, ErrHandle) { + t.Fatalf("cancel sentinel: %v", err) + } + if err := Close(NoCancel); !errors.Is(err, ErrHandle) { + t.Fatalf("close sentinel: %v", err) + } + if ctx.Err() != nil { + t.Fatal("sentinel cancellation/closure affected resource work") + } + target := resource + if closeParent { + target = parent + } + closed := make(chan error, 1) + go func() { closed <- Close(target) }() + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("resource closure did not finish") + } + if err := <-result; ErrorCode(err) != Closed { + t.Fatalf("blocked call = %v, want TC_CLOSED", err) + } + }) + } +} + +func TestNoCancelDoesNotRelaxNonzeroHandleValidation(t *testing.T) { + token := testToken(t, -1) + if token == NoCancel { + t.Fatal("allocated token overlaps sentinel") + } + if err := Close(token); err != nil { + t.Fatal(err) + } + if _, err := tokenContext(token); !errors.Is(err, ErrHandle) { + t.Fatalf("closed token: %v", err) + } + connection, _ := testPipe(t) + if _, err := tokenContext(connection); !errors.Is(err, ErrType) { + t.Fatalf("wrong resource type: %v", err) + } +} diff --git a/internal/capitest/main.go b/internal/capitest/main.go new file mode 100644 index 000000000..6ac70ebdf --- /dev/null +++ b/internal/capitest/main.go @@ -0,0 +1,148 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Command capitest provides a local DERP/STUN relay and HTTP services for foreign +// language integration tests. It prints one JSON record and runs until stdin EOF. +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strconv" + "sync/atomic" + "time" + + "github.com/tailscale/tailcat" + "tailscale.com/derp/derpserver" + "tailscale.com/envknob" + "tailscale.com/net/stun" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + "tailscale.com/types/logger" +) + +func main() { + cert := flag.String("cert", "", "TLS certificate") + certKey := flag.String("key", "", "TLS private key") + flag.Parse() + envknob.Setenv("IN_TS_TEST", "true") + d := derpserver.New(key.NewNode(), logger.Discard) + defer d.Close() + relay := httptest.NewUnstartedServer(derpserver.Handler(d)) + relay.Config.ErrorLog = logger.StdLogger(logger.Discard) + relay.StartTLS() + defer relay.Close() + udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + check(err) + defer udp.Close() + go func() { + buf := make([]byte, 65536) + for { + n, addr, err := udp.ReadFromUDPAddrPort(buf) + if err != nil { + return + } + tx, err := stun.ParseBindingRequest(buf[:n]) + if err == nil { + udp.WriteToUDPAddrPort(stun.Response(tx, addr), addr) + } + } + }() + region := &tailcfg.DERPRegion{RegionID: 1, RegionCode: "local", Nodes: []*tailcfg.DERPNode{{ + Name: "local", RegionID: 1, HostName: "127.0.0.1", IPv4: "127.0.0.1", IPv6: "none", + DERPPort: relay.Listener.Addr().(*net.TCPAddr).Port, STUNPort: udp.LocalAddr().(*net.UDPAddr).Port, + InsecureForTests: true, STUNTestIP: "127.0.0.1", + }}} + s := &tailcat.Server{Region: region, Logf: logger.Discard} + defer s.Close() + ln, err := s.Listen(context.Background(), "tcp", ":0") + check(err) + tlsLn, err := s.Listen(context.Background(), "tcp", ":0") + check(err) + type connKey struct{} + var connID atomic.Int64 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Connection-ID", fmt.Sprint(r.Context().Value(connKey{}))) + switch r.URL.Path { + case "/slow": + select { + case <-time.After(3 * time.Second): + case <-r.Context().Done(): + return + } + case "/redirect": + http.Redirect(w, r, r.URL.Query().Get("to"), http.StatusFound) + return + case "/stream": + w.Header().Set("Content-Type", "application/octet-stream") + for i := range 8 { + fmt.Fprintf(w, "chunk-%d\n", i) + w.(http.Flusher).Flush() + select { + case <-time.After(20 * time.Millisecond): + case <-r.Context().Done(): + return + } + } + return + case "/close": + w.Header().Set("Connection", "close") + case "/idle-close": + // Deliberately close without a Connection header to exercise idle EOF probing. + h, ok := w.(http.Hijacker) + if !ok { + panic("no hijacker") + } + conn, rw, err := h.Hijack() + if err != nil { + return + } + fmt.Fprintf(rw, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + rw.Flush() + conn.Close() + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + return + } + w.Header().Add("X-Repeated", "one") + w.Header().Add("X-Repeated", "two") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"method": r.Method, "path": r.URL.RequestURI(), "host": r.Host, "body": string(body), "headers": r.Header}) + }) + makeHTTP := func() *http.Server { + return &http.Server{Handler: handler, ErrorLog: logger.StdLogger(logger.Discard), ConnContext: func(ctx context.Context, c net.Conn) context.Context { + return context.WithValue(ctx, connKey{}, connID.Add(1)) + }} + } + httpServer, httpsServer := makeHTTP(), makeHTTP() + defer httpServer.Close() + defer httpsServer.Close() + go httpServer.Serve(ln) + pair, err := tls.LoadX509KeyPair(*cert, *certKey) + check(err) + go httpsServer.Serve(tls.NewListener(tlsLn, &tls.Config{Certificates: []tls.Certificate{pair}, NextProtos: []string{"http/1.1"}})) + port := func(ln net.Listener) int { + _, p, _ := net.SplitHostPort(ln.Addr().String()) + n, _ := strconv.Atoi(p) + return n + } + check(json.NewEncoder(os.Stdout).Encode(map[string]any{"address": s.TailcatAddr(), "region": region, "http_port": port(ln), "https_port": port(tlsLn)})) + io.Copy(io.Discard, os.Stdin) +} + +func check(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/listen.go b/listen.go index 78f8bfe46..ac18dfeb6 100644 --- a/listen.go +++ b/listen.go @@ -151,11 +151,28 @@ func (ln *listener) handle(c net.Conn) { // Accept waits for and returns the next connection. For UDP listeners, the // returned [net.Conn] is one client flow and also implements [ConnPacketConn]. func (ln *listener) Accept() (net.Conn, error) { + return ln.AcceptContext(context.Background()) +} + +// ContextListener is a listener whose pending accepts can be cancelled without +// closing the listener. Listeners returned by Server.Listen implement it. +type ContextListener interface { + net.Listener + AcceptContext(context.Context) (net.Conn, error) +} + +// AcceptContext waits for a connection, listener closure, or cancellation. +func (ln *listener) AcceptContext(ctx context.Context) (net.Conn, error) { + if err := ctx.Err(); err != nil { + return nil, err + } select { case c := <-ln.conns: return c, nil case <-ln.closedc: return nil, net.ErrClosed + case <-ctx.Done(): + return nil, ctx.Err() } } diff --git a/tailcat.go b/tailcat.go index 48072cef9..f9c3341ef 100644 --- a/tailcat.go +++ b/tailcat.go @@ -565,9 +565,15 @@ const DefaultUDPIdleTimeout = 2 * time.Minute // It returns an error if the server was already started, including // implicitly by [Server.Listen]. func (s *Server) Start() error { + return s.StartContext(context.Background()) +} + +// StartContext is Start with a context bounding relay discovery and startup. +// Cancelling ctx after startup does not stop the server; use Close instead. +func (s *Server) StartContext(ctx context.Context) error { s.startMu.Lock() defer s.startMu.Unlock() - return s.startLocked(context.Background()) + return s.startLocked(ctx) } // startLocked implements Start, with ctx bounding the startup network From 324190eb2fe61fea2e57e7b570f6c8e6ed6d13e3 Mon Sep 17 00:00:00 2001 From: Vladimir Vukicevic Date: Wed, 23 Sep 2026 10:54:31 -0700 Subject: [PATCH 2/2] cmd/libtailcat: build and test the Windows DLL in CI libtailcat already builds as a DLL with MinGW-w64 gcc, Go's default CC on Windows, but CI skipped the C smoke test there and nothing documented how MSVC users, who cannot build the DLL themselves, link against it. Run the smoke test step on all three runners under bash, picking the library suffix from the runner OS and building into a relative build/ directory so Git Bash needs no path conversion and Windows finds the DLL next to smoke.exe. Add a Windows-only step that builds an import library from a checked-in libtailcat.def with lib /def:, compiles the smoke test with cl /W4 /WX, and runs it against the MinGW-built DLL. A test in internal/capi keeps libtailcat.def, the prototypes in tailcat.h, and the //export directives in main.go in agreement. Document the Windows toolchain requirements and the import library step in the libtailcat README. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/test.yml | 26 +++++++++++--- .gitignore | 3 ++ cmd/libtailcat/README.md | 29 ++++++++++++++++ cmd/libtailcat/libtailcat.def | 36 ++++++++++++++++++++ internal/capi/exports_test.go | 64 +++++++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 cmd/libtailcat/libtailcat.def create mode 100644 internal/capi/exports_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f1992e93..37ec0b257 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,12 +25,28 @@ jobs: # long timeout. actions/setup-go's caches keep warm runs fast. - run: go test -count=1 -timeout 600s ./... - run: go vet ./... - - name: build libtailcat and run its C smoke test - if: runner.os != 'Windows' + - name: build libtailcat and run smoke test + shell: bash run: | - CGO_ENABLED=1 go build -buildmode=c-shared -o /tmp/libtailcat.so ./cmd/libtailcat - cc -Wall -Wextra -Werror -o /tmp/smoke cmd/libtailcat/testdata/smoke.c -Icmd/libtailcat /tmp/libtailcat.so - LD_LIBRARY_PATH=/tmp DYLD_LIBRARY_PATH=/tmp /tmp/smoke + case "$RUNNER_OS" in + Linux) lib=libtailcat.so exe= ;; + macOS) lib=libtailcat.dylib exe= ;; + Windows) lib=libtailcat.dll exe=.exe ;; + esac + mkdir -p build + CGO_ENABLED=1 go build -buildmode=c-shared -o "build/$lib" ./cmd/libtailcat + gcc -Wall -Wextra -Werror -o "build/smoke$exe" cmd/libtailcat/testdata/smoke.c -Icmd/libtailcat "build/$lib" + LD_LIBRARY_PATH=build DYLD_LIBRARY_PATH=build "build/smoke$exe" + # MSVC cannot build the DLL itself, but it can link with it + - name: build and run MSVC libtailcat smoke test + if: runner.os == 'Windows' + shell: cmd + run: | + for /f "usebackq delims=" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -property installationPath`) do set "VSDIR=%%i" + call "%VSDIR%\VC\Auxiliary\Build\vcvars64.bat" || exit /b 1 + lib /nologo /def:cmd\libtailcat\libtailcat.def /machine:x64 /out:build\libtailcat.lib || exit /b 1 + cl /nologo /W4 /WX /Icmd\libtailcat /Fobuild\ /Febuild\smoke-msvc.exe cmd\libtailcat\testdata\smoke.c /link build\libtailcat.lib || exit /b 1 + build\smoke-msvc.exe tidy: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 5ab7b0806..33664cac6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ # Go test artifacts. *.test *.out + +# libtailcat build output (see cmd/libtailcat/README.md). +/build diff --git a/cmd/libtailcat/README.md b/cmd/libtailcat/README.md index 5bb83f1c2..80ff411be 100644 --- a/cmd/libtailcat/README.md +++ b/cmd/libtailcat/README.md @@ -11,6 +11,35 @@ specified by `go.mod` are required. Production builds should use the tags in `build-tags.txt`. `tailcat.h` is the public ABI definition; the generated `libtailcat.h` is a build artifact. ABI version 1 is returned by `tc_abi_version`. +## Windows + +cgo requires a GCC-compatible compiler, so the DLL is built with MinGW-w64 `gcc` +(WinLibs, MSYS2, or Chocolatey's `mingw` package), which is also Go's default +`CC` on Windows. MSVC cannot build the DLL but can consume it: + +```powershell +$env:CGO_ENABLED = "1" +go build -buildmode=c-shared -o build\libtailcat.dll .\cmd\libtailcat +``` + +The resulting DLL depends only on `kernel32` and the C runtime the toolchain +targets (UCRT for current MinGW-w64 builds). Callers must be able to find it: +place it next to the executable or on `PATH`. Loading it with `LoadLibrary` +or an FFI layer such as ctypes or P/Invoke needs nothing else. MinGW links +against the DLL directly (`gcc smoke.c build\libtailcat.dll`). MSVC needs an +import library, built from the checked-in export list without any MinGW tools: + +```bat +lib /def:cmd\libtailcat\libtailcat.def /machine:x64 /out:build\libtailcat.lib +cl /W4 /Icmd\libtailcat smoke.c /link build\libtailcat.lib +``` + +`libtailcat.def` must list every function in `main.go`; `internal/capi` has a +test that keeps it, `tailcat.h`, and the `//export` directives in sync. Memory +returned by the library is allocated by the DLL's own C runtime, which is why +it must be released with `tc_free` and never with the caller's `free`. +CI builds the DLL with MinGW and links the smoke test with both toolchains. + ## Ownership and errors All handles are opaque 64-bit integers. Zero is invalid as a resource handle; diff --git a/cmd/libtailcat/libtailcat.def b/cmd/libtailcat/libtailcat.def new file mode 100644 index 000000000..33b252f8d --- /dev/null +++ b/cmd/libtailcat/libtailcat.def @@ -0,0 +1,36 @@ +; Copyright (c) Tailscale Inc & contributors +; SPDX-License-Identifier: BSD-3-Clause +; +; Module definition for libtailcat.dll: every function exported from +; main.go, for building an MSVC import library without MinGW tools: +; +; lib /def:libtailcat.def /machine:x64 /out:libtailcat.lib +; +; internal/capi/exports_test.go keeps this list in sync with main.go +; and tailcat.h. +LIBRARY libtailcat.dll +EXPORTS +tc_abi_version +tc_address_parse +tc_address_resolve +tc_build_info +tc_client_dial +tc_client_disco_ping +tc_client_new +tc_client_ping +tc_close +tc_conn_close_write +tc_conn_read +tc_conn_readable +tc_conn_write +tc_drain +tc_free +tc_info +tc_key_generate +tc_listener_accept +tc_server_allow_client +tc_server_listen +tc_server_new +tc_server_start +tc_token_cancel +tc_token_new diff --git a/internal/capi/exports_test.go b/internal/capi/exports_test.go new file mode 100644 index 000000000..7d486658d --- /dev/null +++ b/internal/capi/exports_test.go @@ -0,0 +1,64 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package capi + +import ( + "os" + "regexp" + "slices" + "strings" + "testing" +) + +// TestExportsInSync asserts that cmd/libtailcat's three descriptions +// of the C ABI agree: the //export directives in main.go (what the +// shared library actually exports), the prototypes in tailcat.h (what +// C callers compile against), and libtailcat.def (from which MSVC +// users build an import library with lib /def:). The files are read +// as plain text, like internal/buildtags does for build-tags.txt. +func TestExportsInSync(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile("../../cmd/libtailcat/" + name) + if err != nil { + t.Fatal(err) + } + return strings.ReplaceAll(string(b), "\r\n", "\n") + } + names := func(text string, re *regexp.Regexp) []string { + var out []string + for _, m := range re.FindAllStringSubmatch(text, -1) { + out = append(out, m[1]) + } + slices.Sort(out) + return out + } + + exports := names(read("main.go"), regexp.MustCompile(`(?m)^//export (tc_\w+)$`)) + if len(exports) == 0 { + t.Fatal("found no //export directives in main.go") + } + if dup := slices.Compact(slices.Clone(exports)); len(dup) != len(exports) { + t.Errorf("main.go exports contain duplicates: %v", exports) + } + + header := names(read("tailcat.h"), regexp.MustCompile(`(?m)^(?:u?int32_t|void) (tc_\w+)\(`)) + if !slices.Equal(header, exports) { + t.Errorf("tailcat.h prototypes do not match main.go exports\n got: %v\nwant: %v", header, exports) + } + + def := read("libtailcat.def") + _, body, ok := strings.Cut(def, "\nEXPORTS\n") + if !ok { + t.Fatal("libtailcat.def has no EXPORTS section") + } + var listed []string + for _, line := range strings.Split(body, "\n") { + if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, ";") { + listed = append(listed, line) + } + } + if !slices.Equal(listed, exports) { + t.Errorf("libtailcat.def EXPORTS do not match main.go exports (list them sorted, one per line)\n got: %v\nwant: %v", listed, exports) + } +}