Skip to content

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

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

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

Conversation

@josuediazflores

@josuediazflores josuediazflores commented Sep 2, 2026

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, addr, public_key, status_json, close, plus tailcat_accept and tailcat_conn_info (peer address and the port dialed)
  • client: tailcat_client_new (an address), 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_addr, addr_parse, addr_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 address built by hand, since Server.TailcatAddr() 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), TailcatAddress, 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. Rebased on main after the ConnBlob to Addr rename (cc6db49); the bindings use only the new names and call the concept a tailcat address throughout, matching #42. 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 address helpers (including the README address), 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 an address
    printf 'hi there\n' | go run ./cmd/tailcat <address> 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 <address> 8080   # HTTP/1.0 200 OK ...

Known limitations and possible follow-ups

  • tailcat_server_start returns once the server is configured and its address is known; the relay connection completes in the background. Since 877c996 pings resend until acknowledged, a ping right after start succeeds within its timeout, so the bindings carry no retry workarounds. An exported readiness wait on the server (the package's own tests use an unexported health-tracker hook) would still let callers 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

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