Skip to content

libtailcat, swift: add C bindings and a TailcatKit Swift package - #64

Open
josuediazflores wants to merge 4 commits into
tailscale:mainfrom
josuediazflores:swift-bindings
Open

libtailcat, swift: add C bindings and a TailcatKit Swift package#64
josuediazflores wants to merge 4 commits into
tailscale:mainfrom
josuediazflores:swift-bindings

Conversation

@josuediazflores

Copy link
Copy Markdown

Addresses #5 (C bindings), and goes one step further with a Swift package on top so macOS and iOS apps can embed tailcat without a Go toolchain in the app project.

What's in the PR

libtailcat/: a C API over the tailcat library, built with go build -buildmode=c-archive. It follows the design of libtailscale: integer handles, connections and listeners handed to C as one end of a socketpair(2) (so callers just read/write/poll/close), and a hand-written include/tailcat.h fronted by a small tailcat.c shim for const-correct signatures. 30 functions:

  • server: tailcat_server_new, set_key (a tailcat genkey key file), set_region_id, set_relay_hosts (bring your own DERP), set_derpmap_url, set_embed_relay, allow_client, listen (a port, or 0 as a catch-all; before or after start), start, token, public_key, status_json, close, plus tailcat_accept and tailcat_conn_info (peer address and the port dialed)
  • client: tailcat_client_new (a token), set_key, set_derpmap_url, public_key, ping, path_json (DiscoPing: direct endpoint or relay), dial, close
  • no handle: tailcat_key_generate, key_public, key_token, token_parse, token_resolve (the genkey/parse/resolve subcommands)
  • tailcat_errmsg and tailcat_set_logfd

The server start sequence mirrors cmd/tailcat (region resolution, the embed decision before Expand, the same region field cleanup, and the short token built by hand, since Server.ConnBlob() always embeds the relay). OnTCP is a single dispatcher installed at start that consults a port table per connection, so listeners can be added after start. A Makefile builds macOS (arm64 + x86_64), iOS and iOS simulator (arm64 + x86_64) archives with build-tags.txt and packages them as CTailcat.xcframework.

swift/: TailcatKit, a SwiftPM package (Swift 6 language mode, strict concurrency, macOS 14 / iOS 17) wrapping that header in async/await actors: TailcatServer, TailcatClient, Listener, a DispatchIO-backed Connection (half-close via closeWrite()), Identity (the key file), ConnectionToken, and RelaySelection. Blocking C calls run on a dedicated dispatch queue, never on an actor or the cooperative pool. It ships a tailcat-demo executable that doubles as the interop check against the Go CLI, and 35 tests.

No existing Go files are touched. The commits are separable: the first is the C bindings alone, if the Swift package is more than this repo wants to carry.

Design notes worth a look

  • Half-close teardown differs from libtailscale. libtailscale tears a connection down when the first copy goroutine exits, which makes shutdown(SHUT_WR) from C close both directions. Here each direction's EOF propagates as a half-close (CloseWrite/CloseRead) and teardown waits for both, like tailcat.ProxyConns, so a netcat-style client can send, half-close, and still read the reply.
  • SO_NOSIGPIPE is set on every socketpair end on Darwin, and every descriptor is close-on-exec (via ForkLock + CloseOnExec, since Darwin has no SOCK_CLOEXEC), so a C or Swift thread writing to a dead peer cannot kill the process and spawned children do not inherit tunnels.
  • The client identity is generated eagerly in tailcat_client_new so public_key never blocks behind an in-flight first dial (which holds startMu).
  • Every file is constrained on cgo, so CGO_ENABLED=0 go build ./... skips the package instead of failing.

Testing

  • go test ./libtailcat/ -race: an offline end-to-end test over tstest/integration.RunDERPAndSTUN exercising the whole surface through the exported functions and real descriptors: allow-list rejection then acceptance, ping, dial, accept, conn_info, half-close in both directions, catch-all listener, RST for an unregistered port, close-on-exec, key and token helpers (including the README token), and that no handles, listeners or connections leak after close.
  • go test ./..., go vet ./..., go mod tidy (no diff), and cross-builds for linux/windows with cgo off and js/wasm ./web all pass.
  • cd libtailcat && make xcframework produces the three slices; swift build -c release and swift build --build-tests with -warnings-as-errors are clean; swift test passes offline, and TAILCAT_E2E=1 swift test adds a server/client round trip over the public relays. xcodebuild for generic/platform=iOS and the simulator both succeed.
  • Interop with the Go CLI over the public relays, both directions:
    swift run tailcat-demo serve 7777                       # prints a token
    printf 'hi there\n' | go run ./cmd/tailcat <token> 7777 # HI THERE
    
    go run ./cmd/tailcat serve 8080                         # with python3 -m http.server 8080 behind it
    printf 'GET / HTTP/1.0\r\n\r\n' | swift run tailcat-demo connect <token> 8080   # HTTP/1.0 200 OK ...

Known limitations and possible follow-ups

  • tailcat_server_start returns when Server.Start does, before the relay connection is up, and there is no exported readiness signal (the package's own tests use an unexported hook on the health tracker). A client's very first ping can therefore time out; the demo and tests retry. A small exported wait, something like Server.WaitForDERP(ctx), would let start block until reachable. Happy to send that separately if wanted.
  • The allow-list gates registration only: a client that registered while the list was empty stays connected. Documented; eviction would need an upstream API.
  • Listener and Connection allow one accept/receive in flight at a time.
  • The xcframework is a build output (ignored); distributing it as a release asset with a checksummed binaryTarget URL would make the package a plain Package.swift dependency for apps.

Written with Claude Code; I reviewed the design and ran the verification above on an Apple silicon Mac (Go 1.27, Xcode 26.6).

🤖 Generated with Claude Code

With CGO_ENABLED=0 the file that imports "C" drops out of the build but
the helper files did not, leaving a package main with no main function.
Constrain every file on cgo so the package is skipped instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant