diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13df00a60..37ec0b257 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +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 smoke test + shell: bash + run: | + 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 new file mode 100644 index 000000000..80ff411be --- /dev/null +++ b/cmd/libtailcat/README.md @@ -0,0 +1,142 @@ +# 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`. + +## 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; +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/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/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/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) + } +} 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