diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 37ec0b257..a23b3161c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -47,6 +47,24 @@ jobs:
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
+ swift:
+ runs-on: macos-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ with:
+ go-version-file: go.mod
+ - name: build XCFramework and run Swift tests with a local relay
+ run: make -C swift test
+ - name: build Swift release with warnings as errors
+ working-directory: swift
+ run: swift build -c release -Xswiftc -warnings-as-errors
+ - name: compile Swift for iOS devices and simulator
+ working-directory: swift
+ run: |
+ xcodebuild -scheme TailcatKit -destination 'generic/platform=iOS' -derivedDataPath build/device build CODE_SIGNING_ALLOWED=NO SWIFT_TREAT_WARNINGS_AS_ERRORS=YES
+ xcodebuild -scheme TailcatKit -destination 'generic/platform=iOS Simulator' -derivedDataPath build/simulator build CODE_SIGNING_ALLOWED=NO SWIFT_TREAT_WARNINGS_AS_ERRORS=YES
tidy:
runs-on: ubuntu-latest
steps:
diff --git a/README.md b/README.md
index 3082f93ef..a78af9fbb 100644
--- a/README.md
+++ b/README.md
@@ -827,3 +827,12 @@ Go module client of the tailscale.com repo instead of a fork of it.
It was open sourced August 2026 at the
[TailscaleUp conference](https://tailscale.com/tailscaleup).
+
+## Swift / C bindings
+
+[`cmd/libtailcat/`](./cmd/libtailcat/) provides the C API.
+[`swift/`](./swift/) wraps that API as `TailcatKit`, a Swift 6 package with
+async/await servers, clients, and TCP connections for macOS 14+ and iOS 17+.
+Its Makefile builds the upstream C API as `CTailcat.xcframework` for macOS,
+iOS, and the simulator. See the [Swift README](./swift/README.md) for building,
+examples, and local relay tests.
diff --git a/swift/.gitignore b/swift/.gitignore
new file mode 100644
index 000000000..f9643664b
--- /dev/null
+++ b/swift/.gitignore
@@ -0,0 +1,6 @@
+# Built by `make xcframework` in this directory.
+/CTailcat.xcframework
+# SwiftPM, xcodebuild (-derivedDataPath build) and Xcode state.
+/.build
+/build
+/.swiftpm
diff --git a/swift/Makefile b/swift/Makefile
new file mode 100644
index 000000000..35a14f69c
--- /dev/null
+++ b/swift/Makefile
@@ -0,0 +1,77 @@
+# Copyright (c) Tailscale Inc & contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+# Builds libtailcat as Go c-archives for macOS (arm64 + x86_64), iOS
+# devices (arm64) and the iOS simulator (arm64 + x86_64), and packages
+# them with the upstream C header as CTailcat.xcframework.
+#
+# Every archive is built with the release build tags from
+# ../build-tags.txt, which trim the tailscale.com dependency down to
+# what tailcat needs.
+
+GO ?= go
+TAGS := $(shell cat ../build-tags.txt)
+BUILD := build
+SCRIPT := $(abspath script)
+XCFRAMEWORK := CTailcat.xcframework
+
+# `?=` so the environment can override them.
+MACOS_TARGET ?= 14.0
+
+export CGO_ENABLED = 1
+export MACOS_TARGET
+
+.PHONY: all
+all: xcframework ## Builds everything (the default)
+
+$(BUILD):
+ mkdir -p $(BUILD)
+
+# macOS: arm64 natively, x86_64 through the clang wrapper, then lipo.
+.PHONY: macos
+macos: $(BUILD) ## Builds the fat macOS archive build/libtailcat_macos.a
+ MACOSX_DEPLOYMENT_TARGET=$(MACOS_TARGET) GOOS=darwin GOARCH=arm64 \
+ CGO_CFLAGS="-mmacos-version-min=$(MACOS_TARGET)" CGO_LDFLAGS="-mmacos-version-min=$(MACOS_TARGET)" \
+ $(GO) build -buildmode=c-archive -tags "$(TAGS)" -ldflags "-s -w" -o $(BUILD)/libtailcat_macos_arm64.a ../cmd/libtailcat
+ MACOSX_DEPLOYMENT_TARGET=$(MACOS_TARGET) GOOS=darwin GOARCH=amd64 CC=$(SCRIPT)/clangwrap-macos-x86.sh \
+ $(GO) build -buildmode=c-archive -tags "$(TAGS)" -ldflags "-s -w" -o $(BUILD)/libtailcat_macos_x86_64.a ../cmd/libtailcat
+ lipo -create -output $(BUILD)/libtailcat_macos.a $(BUILD)/libtailcat_macos_arm64.a $(BUILD)/libtailcat_macos_x86_64.a
+
+.PHONY: ios
+ios: $(BUILD) ## Builds the iOS device archive build/libtailcat_ios.a (iOS SDK required)
+ GOOS=ios GOARCH=arm64 CC=$(SCRIPT)/clangwrap-ios.sh \
+ $(GO) build -buildmode=c-archive -tags "ios,$(TAGS)" -ldflags -w -o $(BUILD)/libtailcat_ios.a ../cmd/libtailcat
+
+.PHONY: ios-sim
+ios-sim: $(BUILD) ## Builds the fat iOS simulator archive build/libtailcat_iossim.a (iOS SDK required)
+ GOOS=ios GOARCH=arm64 CC=$(SCRIPT)/clangwrap-ios-sim-arm.sh \
+ $(GO) build -buildmode=c-archive -tags "ios,$(TAGS)" -ldflags -w -o $(BUILD)/libtailcat_iossim_arm64.a ../cmd/libtailcat
+ GOOS=ios GOARCH=amd64 CC=$(SCRIPT)/clangwrap-ios-sim-x86.sh \
+ $(GO) build -buildmode=c-archive -tags "ios,$(TAGS)" -ldflags -w -o $(BUILD)/libtailcat_iossim_x86_64.a ../cmd/libtailcat
+ lipo -create -output $(BUILD)/libtailcat_iossim.a $(BUILD)/libtailcat_iossim_arm64.a $(BUILD)/libtailcat_iossim_x86_64.a
+
+.PHONY: xcframework
+xcframework: macos ios ios-sim ## Builds CTailcat.xcframework from the three archives
+ mkdir -p $(BUILD)/include
+ cp ../cmd/libtailcat/tailcat.h include/module.modulemap $(BUILD)/include/
+ rm -rf $(XCFRAMEWORK)
+ mkdir -p $(dir $(XCFRAMEWORK))
+ xcodebuild -create-xcframework \
+ -library $(BUILD)/libtailcat_macos.a -headers $(BUILD)/include \
+ -library $(BUILD)/libtailcat_ios.a -headers $(BUILD)/include \
+ -library $(BUILD)/libtailcat_iossim.a -headers $(BUILD)/include \
+ -output $(XCFRAMEWORK)
+
+.PHONY: test
+test: xcframework ## Rebuilds the native library and runs Swift tests against a local relay
+ cd .. && $(GO) test -v -count=1 -timeout 180s ./swift/testdata
+
+.PHONY: clean
+clean: ## Removes build/ and the xcframework
+ rm -rf $(BUILD) $(XCFRAMEWORK)
+
+.PHONY: help
+help: ## Shows this help
+ @printf '\nSpecify a target. The choices are:\n\n'
+ @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
+ @printf '\n'
diff --git a/swift/Package.swift b/swift/Package.swift
new file mode 100644
index 000000000..e435192cd
--- /dev/null
+++ b/swift/Package.swift
@@ -0,0 +1,36 @@
+// swift-tools-version: 6.0
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import PackageDescription
+
+let package = Package(
+ name: "TailcatKit",
+ platforms: [.macOS(.v14), .iOS(.v17)],
+ products: [
+ .library(name: "TailcatKit", targets: ["TailcatKit"]),
+ .executable(name: "tailcat-demo", targets: ["tailcat-demo"]),
+ ],
+ targets: [
+ // The Go library as a static archive per platform, built by
+ // `make xcframework` in this directory, using ../cmd/libtailcat.
+ .binaryTarget(name: "CTailcat", path: "CTailcat.xcframework"),
+ .target(
+ name: "TailcatKit",
+ dependencies: ["CTailcat"],
+ linkerSettings: [
+ // The Go runtime and its net package need these on Darwin.
+ .linkedFramework("CoreFoundation"),
+ .linkedFramework("Security"),
+ .linkedLibrary("resolv"),
+ ]
+ ),
+ .executableTarget(
+ name: "tailcat-demo",
+ dependencies: ["TailcatKit"],
+ path: "Sources/TailcatDemo"
+ ),
+ .testTarget(name: "TailcatKitTests", dependencies: ["TailcatKit"]),
+ ],
+ swiftLanguageModes: [.v6]
+)
diff --git a/swift/README.md b/swift/README.md
new file mode 100644
index 000000000..d890a615b
--- /dev/null
+++ b/swift/README.md
@@ -0,0 +1,174 @@
+# TailcatKit
+
+A Swift 6 package wrapping the upstream [libtailcat C API](../cmd/libtailcat/README.md)
+for macOS 14+ and iOS 17+. `TailcatServer` and `TailcatClient` expose async TCP
+connections through opaque C handles, with Swift task cancellation and deadlines.
+
+## Building
+
+From this directory, with Go and Xcode (including the iOS SDK) installed:
+
+```sh
+make test
+swift build -c release -Xswiftc -warnings-as-errors
+```
+
+The Makefile builds `../cmd/libtailcat` with `-buildmode=c-archive` and the release
+tags from `../build-tags.txt`. It copies the upstream `tailcat.h` into the
+XCFramework alongside a Swift module map. No separate C implementation is needed.
+The framework contains macOS arm64/x86_64, iOS arm64, and simulator arm64/x86_64.
+
+Add the built `swift` directory as a local Swift package dependency in Xcode.
+The framework is an ignored build output, so a remote SwiftPM dependency is not
+self-contained until binary distribution is added. The package links the Darwin
+frameworks and libraries needed by the Go runtime.
+
+## Usage
+
+A server that echoes TCP connections:
+
+```swift
+import Foundation
+import TailcatKit
+
+let server = try TailcatServer()
+let listener = try await server.listen(on: 8080) // starts the server if needed
+let address = try await server.start() // repeated starts succeed
+// Share address securely with the client. It grants access to the server.
+
+for try await connection in listener.connections {
+ Task {
+ do {
+ for try await chunk in connection.incoming {
+ try await connection.send(chunk)
+ }
+ try await connection.closeWrite()
+ } catch {
+ // Handle cancellation or a transport failure.
+ }
+ await connection.close()
+ }
+}
+```
+
+A client using that address:
+
+```swift
+let client = try TailcatClient(address: address)
+let connection = try await client.connect(port: 8080)
+try await connection.send(Data("hello\n".utf8))
+try await connection.closeWrite()
+for try await chunk in connection.incoming {
+ // Consume the reply. The stream ends at TCP EOF.
+}
+await connection.close()
+try await client.drain(timeout: .seconds(5))
+await client.close()
+```
+
+`listen(on: 0)` allocates a free port, exposed by `Listener.port`. Closing a
+listener leaves accepted connections alive. Closing a server or client retires
+all its child handles, interrupts active calls, and waits for teardown off-thread.
+`close()` is async and idempotent; deinitialization schedules fallback cleanup.
+Prefer explicit close when shutdown completion matters.
+`drain` waits for TCP shutdown traffic before the peer is closed.
+
+Explicit close and deinitialization use a dedicated teardown thread, so shutdown
+can interrupt idle I/O even when the Dispatch worker pool is fully occupied.
+
+### Cancellation and I/O
+
+Every blocking operation has its own C token. Swift task cancellation calls
+`tc_token_cancel`, including when no timeout is supplied. `timeout: nil` means
+no caller deadline; zero or a negative duration expires immediately. Time spent
+waiting on a worker queue counts toward the deadline. C buffers and tokens stay
+alive until the original synchronous call returns. Completion can win a
+cancellation race, in which case the successful result is delivered and owned
+normally.
+
+One receive and one send can run concurrently on worker threads. Overlapping
+receives, or overlapping sends/`closeWrite` calls, throw `invalidArgument`;
+await a send before starting another or half-closing. `receive` returns available
+bytes, up to `maxLength`, and empty `Data` at EOF. Cancelling a read or accept
+leaves the connection or listener usable. `send` handles short writes under one
+deadline. A failure after bytes have been transmitted throws
+`PartialWriteError`, including `bytesWritten` and `underlyingError`; do not replay
+that prefix. If a read returns both bytes and an error, the bytes are delivered
+first and the next receive reports the error.
+
+Errors use C status codes, with messages belonging to the individual operation.
+`TC_CANCELLED` becomes `CancellationError`, `TC_TIMEOUT` becomes
+`TailcatError.timeout`, and `TC_CLOSED` becomes `TailcatError.closed`.
+All C allocations are released with `tc_free`.
+
+### Keys, relays, and diagnostics
+
+```swift
+let identity = try Identity.generate()
+// Store identity.json in the Keychain, including its private and pre-shared keys.
+let server = try TailcatServer(configuration: .init(
+ identity: identity,
+ relay: .region(302),
+ allowedClients: [knownClientKey]
+))
+let info = try address.parse() // public metadata only
+let resolved = try await address.resolved() // embeds relay details
+let latency = try await client.ping() // whole milliseconds; zero is valid
+let path = try await client.path() // direct endpoint or relay region
+```
+
+Identity JSON uses the C API's `private_key`, `public_key`, and `preshared_key`
+fields. It is distinct from the CLI's key-file format. Restore the complete bundle
+to preserve a server's identity and capability. Relay selection belongs to
+`ServerConfiguration`, using `.automatic`, `.region(id)`, or `.custom(data)` with
+a DERPRegion JSON object. The server's address is available after `start()`.
+
+The allowlist restricts subsequent registration and does not disconnect existing
+flows. Configure it before startup when access should be restricted immediately.
+Addresses and private/pre-shared keys are secrets. The wrapper never logs them.
+`LogSink` receives Swift lifecycle messages; the C API discards Go logs.
+
+### Changes from the original PR API
+
+- The upstream `tc_*` API replaces the private `tailcat_*` layer and socketpairs.
+- `listen` starts the server and port zero chooses a free port. There is no
+ catch-all listener, and `start` is idempotent.
+- `closeWrite` is async and throwing; all resource `close` methods are async.
+- Identity JSON uses the C API key bundle. `Identity.address()` is removed;
+ obtain addresses from a started server.
+- `.hosts` is replaced by `.custom` DERPRegion JSON. The C API controls address
+ encoding, so `embedRelayInAddress` is removed.
+- The old WireGuard `status()` and Go log descriptor APIs are not exposed by
+ the upstream C API. `path`, `ping`, and connection endpoint metadata remain.
+- This package wraps TCP. The upstream C API also supports UDP.
+
+## Tests and demo
+
+`make test` rebuilds the XCFramework from current Go sources, then uses a Go test
+fixture to run a local DERP/STUN server for the Swift test process. It exercises
+the actual C archive, including half-close, deadlines, read/accept cancellation
+and reuse, partial writes, shutdown under load, child ownership, keys, and address
+helpers, without relying on public relays. `swift test` alone runs the
+value/lifecycle tests against the previously built XCFramework and skips tests
+requiring the local relay fixture. The macOS CI job runs `make test`, a Swift
+release build, and iOS device/simulator compilation on pull requests and main.
+
+```sh
+swift run tailcat-demo serve 7777
+swift run tailcat-demo connect
7777
+swift run tailcat-demo parse
+swift run tailcat-demo genkey
+```
+
+The demo prints a secret address for sharing when serving and secret key material
+for `genkey`; protect that output. Set `TAILCAT_VERBOSE=1` for Swift lifecycle logs.
+The demo interoperates with `go run ./cmd/tailcat 7777` from the repo root.
+
+## iOS compilation
+
+From this directory:
+
+```sh
+xcodebuild -scheme TailcatKit -destination 'generic/platform=iOS' -derivedDataPath build/device build CODE_SIGNING_ALLOWED=NO
+xcodebuild -scheme TailcatKit -destination 'generic/platform=iOS Simulator' -derivedDataPath build/simulator build CODE_SIGNING_ALLOWED=NO
+```
diff --git a/swift/Sources/TailcatDemo/main.swift b/swift/Sources/TailcatDemo/main.swift
new file mode 100644
index 000000000..4e42c8e20
--- /dev/null
+++ b/swift/Sources/TailcatDemo/main.swift
@@ -0,0 +1,148 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+// tailcat-demo exercises TailcatKit from the command line and doubles as
+// the interop check against the Go tailcat CLI.
+//
+// tailcat-demo serve start a server, print its address on stderr,
+// echo every connection's bytes back uppercased
+// tailcat-demo connect ping (print latency and path), connect, send
+// stdin, print what comes back until EOF
+// tailcat-demo parse print the address's contents
+// tailcat-demo genkey print a new identity JSON and its public key
+//
+// Set TAILCAT_VERBOSE=1 to see Swift lifecycle messages on stderr.
+
+import Foundation
+import TailcatKit
+
+func note(_ message: String) {
+ FileHandle.standardError.write(Data((message + "\n").utf8))
+}
+
+func fail(_ message: String) -> Never {
+ note("tailcat-demo: \(message)")
+ exit(1)
+}
+
+func makeLogger() -> any LogSink {
+ ProcessInfo.processInfo.environment["TAILCAT_VERBOSE"] == "1" ? DefaultLogger() : BlackholeLogger()
+}
+
+func parsePort(_ text: String) -> UInt16 {
+ guard let port = UInt16(text) else { fail("invalid port \(text)") }
+ return port
+}
+
+func parseAddress(_ text: String) -> TailcatAddress {
+ guard let address = TailcatAddress(rawValue: text) else { fail("invalid address \(text)") }
+ return address
+}
+
+func milliseconds(_ d: Duration) -> String {
+ let (s, attos) = d.components
+ return String(format: "%.1fms", Double(s) * 1000 + Double(attos) / 1e15)
+}
+
+/// Uppercases ASCII letters, leaving every other byte alone.
+func uppercased(_ data: Data) -> Data {
+ Data(data.map { $0 >= 0x61 && $0 <= 0x7A ? $0 - 0x20 : $0 })
+}
+
+func echo(_ connection: Connection) async {
+ note("# connection from \(connection.remoteAddress ?? "?") on port \(connection.localPort.map(String.init) ?? "?")")
+ do {
+ for try await chunk in connection.incoming {
+ try await connection.send(uppercased(chunk))
+ }
+ try await connection.closeWrite()
+ } catch {
+ note("# connection error: \(error)")
+ }
+ await connection.close()
+ note("# connection closed")
+}
+
+func serve(port: UInt16) async throws {
+ let server = try TailcatServer(configuration: .init(), logger: makeLogger())
+ let listener = try await server.listen(on: port)
+ note("# public key: \(server.publicKey)")
+ let address = try await server.start()
+ note("# Server listening on port \(listener.port) with new address: \(address)")
+ for try await connection in listener.connections {
+ Task { await echo(connection) }
+ }
+}
+
+func connect(address: TailcatAddress, port: UInt16) async throws {
+ let client = try TailcatClient(address: address, logger: makeLogger())
+ let latency = try await client.ping(timeout: .seconds(15))
+ note("# pong in \(milliseconds(latency)) via the relay")
+ let path = try await client.path()
+ if path.isDirect {
+ note("# direct path via \(path.endpoint ?? "?"), \(milliseconds(path.latency))")
+ } else {
+ note("# relayed via DERP(\(path.relayRegionCode ?? "?")), \(milliseconds(path.latency))")
+ }
+ let connection = try await client.connect(port: port)
+ note("# connected to port \(port)")
+ // stdin is read on a detached task: availableData blocks, which is
+ // fine for a command line tool.
+ let sender = Task.detached {
+ do {
+ while true {
+ let chunk = FileHandle.standardInput.availableData
+ if chunk.isEmpty { break }
+ try await connection.send(chunk)
+ }
+ try await connection.closeWrite()
+ } catch {
+ note("# send error: \(error)")
+ }
+ }
+ for try await chunk in connection.incoming {
+ FileHandle.standardOutput.write(chunk)
+ }
+ sender.cancel()
+ await connection.close()
+ try await client.drain()
+ await client.close()
+}
+
+func parse(address: TailcatAddress) throws {
+ let info = try address.parse()
+ print("server public key: \(info.serverPublicKey)")
+ if let region = info.regionID {
+ print("DERP region ID: \(region)")
+ }
+ if !info.relayHosts.isEmpty {
+ print("relay hosts: \(info.relayHosts.joined(separator: ", "))")
+ }
+ print(String(decoding: info.json, as: UTF8.self))
+}
+
+func genkey() throws {
+ let identity = try Identity.generate()
+ print(identity.json)
+ print("public key: \(identity.publicKey)")
+}
+
+let args = Array(CommandLine.arguments.dropFirst())
+do {
+ switch args.first {
+ case "serve" where args.count == 2:
+ try await serve(port: parsePort(args[1]))
+ case "connect" where args.count == 3:
+ try await connect(address: parseAddress(args[1]), port: parsePort(args[2]))
+ case "parse" where args.count == 2:
+ try parse(address: parseAddress(args[1]))
+ case "genkey" where args.count == 1:
+ try genkey()
+ default:
+ note("usage: tailcat-demo serve | connect | parse | genkey")
+ exit(2)
+ }
+} catch {
+ fail("\(error)")
+}
+exit(0)
diff --git a/swift/Sources/TailcatKit/Blocking.swift b/swift/Sources/TailcatKit/Blocking.swift
new file mode 100644
index 000000000..92200380c
--- /dev/null
+++ b/swift/Sources/TailcatKit/Blocking.swift
@@ -0,0 +1,140 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import Dispatch
+import Foundation
+import os
+
+/// Keeps synchronous C operations off actor executors.
+enum Blocking {
+ static let queue = DispatchQueue(label: "dev.tailcat.blocking", qos: .userInitiated, attributes: .concurrent)
+
+ static func perform(on queue: DispatchQueue = queue,
+ _ body: @escaping @Sendable () throws -> T) async throws -> T {
+ try await withCheckedThrowingContinuation { continuation in
+ queue.async { continuation.resume(with: Result(catching: body)) }
+ }
+ }
+
+ /// Each operation gets its own token. Its deadline includes dispatch queue
+ /// time, and Swift cancellation interrupts C without retiring the resource.
+ /// A successful result wins a cancellation race and remains caller-owned.
+ static func run(timeout: Duration? = nil, on queue: DispatchQueue = queue,
+ _ body: @escaping @Sendable (UInt64) throws -> T) async throws -> T {
+ try Task.checkCancellation()
+ let token = try CancellationToken(timeout: timeout)
+ return try await withTaskCancellationHandler {
+ try await perform(on: queue) { try body(token.handle) }
+ } onCancel: {
+ token.cancel()
+ }
+ }
+}
+
+/// A single teardown thread independent of the Dispatch worker pool. Idle C
+/// reads/accepts can occupy every Dispatch worker, so queuing close there would
+/// prevent the very operation that interrupts those calls from ever running.
+private final class Teardown: Sendable {
+ static let shared = Teardown()
+ private let pending = OSAllocatedUnfairLock(initialState: [@Sendable () -> Void]())
+ private let ready = DispatchSemaphore(value: 0)
+
+ private init() {
+ let pending = pending
+ let ready = ready
+ let thread = Thread {
+ while true {
+ ready.wait()
+ let jobs = pending.withLock { pending in
+ let jobs = pending
+ pending.removeAll(keepingCapacity: true)
+ return jobs
+ }
+ for job in jobs { job() }
+ }
+ }
+ thread.name = "dev.tailcat.teardown"
+ thread.start()
+ }
+
+ func submit(_ body: @escaping @Sendable () -> Void) {
+ pending.withLock { $0.append(body) }
+ ready.signal()
+ }
+
+ func close(_ raw: UInt64) async {
+ await withCheckedContinuation { continuation in
+ submit {
+ _ = tc_close(raw, nil)
+ continuation.resume()
+ }
+ }
+ }
+}
+
+private final class CancellationToken: Sendable {
+ let handle: UInt64
+
+ init(timeout: Duration?) throws {
+ var handle: UInt64 = 0
+ try CAPI.check { tc_token_new(timeout?.nanosecondsForC ?? -1, &handle, $0) }
+ self.handle = handle
+ }
+
+ func cancel() { _ = tc_token_cancel(handle, nil) }
+ // The operation and its cancellation handler retain us until both return.
+ deinit { _ = tc_close(handle, nil) }
+}
+
+/// Owns one never-reused C handle. Explicit close and deinit both use the
+/// teardown thread without blocking an actor or the cooperative pool.
+final class Handle: Sendable {
+ private struct State: Sendable {
+ var raw: UInt64
+ var closing: Task?
+ }
+ private let state: OSAllocatedUnfairLock
+
+ init(_ handle: UInt64) { state = OSAllocatedUnfairLock(initialState: State(raw: handle)) }
+
+ func value() throws -> UInt64 {
+ try state.withLock {
+ guard $0.raw != 0 else { throw TailcatError.closed }
+ return $0.raw
+ }
+ }
+
+ func close() async {
+ let closing = state.withLock { state in
+ if let closing = state.closing { return closing }
+ let raw = state.raw
+ state.raw = 0
+ let closing = Task.detached {
+ await Teardown.shared.close(raw)
+ }
+ state.closing = closing
+ return closing
+ }
+ await closing.value
+ }
+
+ deinit {
+ let raw = state.withLock { $0.raw }
+ if raw != 0 { Teardown.shared.submit { _ = tc_close(raw, nil) } }
+ }
+}
+
+extension Duration {
+ /// ABI timeouts are nanoseconds. Round positive fractions up and saturate.
+ /// Zero/negative durations expire immediately; nil means no deadline.
+ var nanosecondsForC: Int64 {
+ guard self > .zero else { return 0 }
+ let (seconds, attoseconds) = components
+ let (whole, overflow) = seconds.multipliedReportingOverflow(by: 1_000_000_000)
+ guard !overflow else { return .max }
+ let fraction = attoseconds / 1_000_000_000 + (attoseconds % 1_000_000_000 > 0 ? 1 : 0)
+ let (result, sumOverflow) = whole.addingReportingOverflow(fraction)
+ return sumOverflow ? .max : result
+ }
+}
diff --git a/swift/Sources/TailcatKit/Connection.swift b/swift/Sources/TailcatKit/Connection.swift
new file mode 100644
index 000000000..a39affae7
--- /dev/null
+++ b/swift/Sources/TailcatKit/Connection.swift
@@ -0,0 +1,141 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import Foundation
+import os
+
+/// A TCP stream backed by an opaque C handle. One receive and one send can run
+/// concurrently. Overlapping receives, or overlapping sends/closeWrite calls,
+/// are rejected so logical sends cannot interleave and cancellation never waits
+/// behind another Swift operation. Await send before calling closeWrite.
+public final class Connection: Sendable {
+ public let remoteAddress: String?
+ public let localAddress: String?
+ public let localPort: UInt16?
+ private let handle: Handle
+ private let state = OSAllocatedUnfairLock(initialState: State())
+
+ private struct State: Sendable {
+ var reading = false
+ var writing = false
+ var eof = false
+ var pendingError: (any Error)?
+ }
+
+ private init(handle: Handle, info: ResourceInfo) {
+ self.handle = handle
+ self.remoteAddress = info.remoteAddress
+ self.localAddress = info.localAddress
+ self.localPort = info.localPort
+ }
+
+ /// Called on a C worker. Metadata lookup can fail after a successful dial or
+ /// accept, so that failure must still release the newly returned handle.
+ static func adopt(_ raw: UInt64, token: UInt64) throws -> Connection {
+ do {
+ let info = try CAPI.info(raw, token)
+ return Connection(handle: Handle(raw), info: info)
+ } catch {
+ _ = tc_close(raw, nil)
+ throw error
+ }
+ }
+
+ /// Returns available bytes up to maxLength, or empty Data at TCP EOF.
+ /// Cancellation/timeout leaves the connection usable. Bytes returned with
+ /// an error are delivered first; the next receive reports that error.
+ public func receive(maxLength: Int = 65_536, timeout: Duration? = nil) async throws -> Data {
+ guard maxLength > 0 else { throw TailcatError.invalidArgument("maxLength must be positive") }
+ let raw = try handle.value()
+ try Task.checkCancellation()
+ try state.withLock { state in
+ guard !state.reading else { throw TailcatError.invalidArgument("a receive is already in progress") }
+ state.reading = true
+ }
+ defer { state.withLock { $0.reading = false } }
+ return try await Blocking.run(timeout: timeout) { [state] token in
+ let eof = try state.withLock { state in
+ if let error = state.pendingError {
+ state.pendingError = nil
+ throw error
+ }
+ return state.eof
+ }
+ if eof { return Data() }
+ var data = Data(count: maxLength)
+ var count = 0
+ var message: UnsafeMutablePointer?
+ let status = data.withUnsafeMutableBytes {
+ tc_conn_read(raw, token, $0.baseAddress, $0.count, &count, &message)
+ }
+ let text = CAPI.take(message)
+ let error = status == TC_EOF ? nil : CAPI.error(status, text)
+ if status == TC_EOF { state.withLock { $0.eof = true } }
+ if count > 0 {
+ data.count = count
+ state.withLock { $0.pendingError = error }
+ return data
+ }
+ if let error { throw error }
+ return Data()
+ }
+ }
+
+ /// Sends all bytes under one deadline. A failure after any bytes were sent
+ /// throws PartialWriteError with their count; cancellation cannot undo them.
+ public func send(_ data: Data, timeout: Duration? = nil) async throws {
+ let raw = try handle.value()
+ try beginWrite()
+ defer { state.withLock { $0.writing = false } }
+ try await Blocking.run(timeout: timeout) { token in
+ var written = 0
+ do {
+ try data.withUnsafeBytes { bytes in
+ while written < bytes.count {
+ var count = 0
+ var message: UnsafeMutablePointer?
+ let status = tc_conn_write(raw, token,
+ UnsafeMutableRawPointer(mutating: bytes.baseAddress!.advanced(by: written)),
+ bytes.count - written, &count, &message)
+ written += count
+ if let error = CAPI.error(status, CAPI.take(message)) { throw error }
+ guard count > 0 else { throw TailcatError.internalError("C write made no progress") }
+ }
+ }
+ } catch {
+ if written > 0 { throw PartialWriteError(bytesWritten: written, underlyingError: error) }
+ throw error
+ }
+ }
+ }
+
+ /// Sends FIN, retaining the ability to receive a reply. Await prior sends
+ /// first; overlapping write operations throw invalidArgument.
+ public func closeWrite(timeout: Duration? = nil) async throws {
+ let raw = try handle.value()
+ try beginWrite()
+ defer { state.withLock { $0.writing = false } }
+ try await Blocking.run(timeout: timeout) { token in
+ try CAPI.check { tc_conn_close_write(raw, token, $0) }
+ }
+ }
+
+ private func beginWrite() throws {
+ try Task.checkCancellation()
+ try state.withLock { state in
+ guard !state.writing else { throw TailcatError.invalidArgument("a write is already in progress") }
+ state.writing = true
+ }
+ }
+
+ public var incoming: AsyncThrowingStream {
+ AsyncThrowingStream(unfolding: { [self] in
+ let data = try await receive()
+ return data.isEmpty ? nil : data
+ })
+ }
+
+ /// Interrupts pending reads/writes and waits for teardown off-thread.
+ public func close() async { await handle.close() }
+}
diff --git a/swift/Sources/TailcatKit/Identity.swift b/swift/Sources/TailcatKit/Identity.swift
new file mode 100644
index 000000000..9765c8cdd
--- /dev/null
+++ b/swift/Sources/TailcatKit/Identity.swift
@@ -0,0 +1,71 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import CryptoKit
+import Foundation
+
+/// The C API key bundle, containing private_key, public_key and preshared_key.
+/// Store the complete JSON securely to preserve a server's identity/capability.
+/// This format is distinct from the CLI's private-key file format.
+public struct Identity: Sendable, Codable, Hashable {
+ public let json: String
+ public let publicKey: NodePublicKey
+ let privateKey: String
+ let presharedKey: String
+
+ public init(json: String) throws {
+ struct Keys: Decodable {
+ var private_key: String
+ var public_key: String
+ var preshared_key: String
+ }
+ do {
+ let keys = try JSONDecoder().decode(Keys.self, from: Data(json.utf8))
+ guard let publicKey = NodePublicKey(rawValue: keys.public_key),
+ !keys.private_key.isEmpty, !keys.preshared_key.isEmpty else {
+ throw TailcatError.invalidKey("missing or invalid keys")
+ }
+ // Let the C API validate private keys. Creation performs no network
+ // work; an unstarted server has no asynchronous teardown to join.
+ let config = try JSONEncoder().encode([
+ "key": keys.private_key, "preshared_key": keys.preshared_key,
+ ])
+ var handle: UInt64 = 0
+ try String(decoding: config, as: UTF8.self).withCInput { input in
+ try CAPI.check { tc_server_new(input, &handle, $0) }
+ }
+ _ = tc_close(handle, nil)
+ // Check that an imported bundle's advertised key belongs to its
+ // private key. CryptoKit uses the same X25519 public-key derivation.
+ let hex = Array(keys.private_key.dropFirst("privkey:".count))
+ let bytes = stride(from: 0, to: hex.count, by: 2).compactMap {
+ UInt8(String(hex[$0..<$0 + 2]), radix: 16)
+ }
+ let derived = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: Data(bytes)).publicKey.rawRepresentation
+ let expected = "nodekey:" + derived.map { String(format: "%02x", $0) }.joined()
+ guard expected == keys.public_key.lowercased() else {
+ throw TailcatError.invalidKey("public and private keys do not match")
+ }
+ self.json = json
+ self.publicKey = publicKey
+ self.privateKey = keys.private_key
+ self.presharedKey = keys.preshared_key
+ } catch {
+ throw TailcatError.invalidKey("invalid C API key bundle")
+ }
+ }
+
+ public static func generate() throws -> Identity {
+ try Identity(json: CAPI.string { tc_key_generate($0, $1) })
+ }
+
+ public init(from decoder: any Decoder) throws {
+ try self.init(json: decoder.singleValueContainer().decode(String.self))
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(json)
+ }
+}
diff --git a/swift/Sources/TailcatKit/Listener.swift b/swift/Sources/TailcatKit/Listener.swift
new file mode 100644
index 000000000..b624f190b
--- /dev/null
+++ b/swift/Sources/TailcatKit/Listener.swift
@@ -0,0 +1,40 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import Foundation
+
+/// A TCP listener. Accepted connections belong to the server and survive close.
+public actor Listener {
+ public nonisolated let port: UInt16
+ private let handle: Handle
+ private let logger: any LogSink
+
+ init(handle: Handle, port: UInt16, logger: any LogSink) {
+ self.handle = handle
+ self.port = port
+ self.logger = logger
+ }
+
+ /// Cancellation interrupts just this accept; the listener remains usable.
+ public func accept(timeout: Duration? = nil) async throws -> Connection {
+ let raw = try handle.value()
+ return try await Blocking.run(timeout: timeout) { token in
+ var connection: UInt64 = 0
+ try CAPI.check { tc_listener_accept(raw, token, &connection, $0) }
+ return try Connection.adopt(connection, token: token)
+ }
+ }
+
+ public nonisolated var connections: AsyncThrowingStream {
+ AsyncThrowingStream(unfolding: { [self] in
+ do { return try await accept() }
+ catch TailcatError.closed { return nil }
+ })
+ }
+
+ public func close() async {
+ await handle.close()
+ logger.log("Listener: closed")
+ }
+}
diff --git a/swift/Sources/TailcatKit/LogSink.swift b/swift/Sources/TailcatKit/LogSink.swift
new file mode 100644
index 000000000..069e423f3
--- /dev/null
+++ b/swift/Sources/TailcatKit/LogSink.swift
@@ -0,0 +1,20 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import os
+
+/// Receives Swift wrapper lifecycle messages. The C API discards Go logs.
+public protocol LogSink: Sendable {
+ func log(_ message: String)
+}
+
+public struct DefaultLogger: LogSink {
+ private static let logger = Logger(subsystem: "dev.tailcat.TailcatKit", category: "TailcatKit")
+ public init() {}
+ public func log(_ message: String) { Self.logger.info("\(message, privacy: .public)") }
+}
+
+public struct BlackholeLogger: LogSink {
+ public init() {}
+ public func log(_ message: String) {}
+}
diff --git a/swift/Sources/TailcatKit/Relay.swift b/swift/Sources/TailcatKit/Relay.swift
new file mode 100644
index 000000000..53bee4e3a
--- /dev/null
+++ b/swift/Sources/TailcatKit/Relay.swift
@@ -0,0 +1,53 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import Foundation
+
+/// Relay configuration accepted by the upstream C API.
+public enum RelaySelection: Sendable, Hashable {
+ case automatic
+ /// A positive DERP region ID from the relay map.
+ case region(Int)
+ /// A DERPRegion JSON object, using Go field names such as RegionID and Nodes.
+ /// Useful for private relays. The C API validates its contents at creation.
+ case custom(Data)
+}
+
+public struct ServerConfiguration: Sendable {
+ public var identity: Identity?
+ public var relay: RelaySelection
+ public var derpMapURL: URL?
+ /// An empty list allows any client possessing the secret server address.
+ public var allowedClients: [NodePublicKey]
+
+ public init(identity: Identity? = nil, relay: RelaySelection = .automatic,
+ derpMapURL: URL? = nil, allowedClients: [NodePublicKey] = []) {
+ self.identity = identity
+ self.relay = relay
+ self.derpMapURL = derpMapURL
+ self.allowedClients = allowedClients
+ }
+
+ func json(identity: Identity) throws -> String {
+ var config: [String: Any] = [
+ "key": identity.privateKey,
+ "preshared_key": identity.presharedKey,
+ "allowed_clients": allowedClients.map(\.rawValue),
+ ]
+ if let derpMapURL { config["derp_map_url"] = derpMapURL.absoluteString }
+ switch relay {
+ case .automatic: break
+ case .region(let id):
+ guard id > 0, id <= Int(Int32.max) else {
+ throw TailcatError.invalidArgument("DERP region ID must be positive and fit in Int32")
+ }
+ config["region_id"] = id
+ case .custom(let json):
+ guard let region = try JSONSerialization.jsonObject(with: json) as? [String: Any] else {
+ throw TailcatError.invalidArgument("relay must be a DERPRegion JSON object")
+ }
+ config["region"] = region
+ }
+ return String(decoding: try JSONSerialization.data(withJSONObject: config), as: UTF8.self)
+ }
+}
diff --git a/swift/Sources/TailcatKit/TailcatAddress.swift b/swift/Sources/TailcatKit/TailcatAddress.swift
new file mode 100644
index 000000000..7cf868035
--- /dev/null
+++ b/swift/Sources/TailcatKit/TailcatAddress.swift
@@ -0,0 +1,164 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import Foundation
+
+/// A node's public key in tailcat's text form, "nodekey:" followed by 64
+/// hex digits. Servers allow clients by this key.
+public struct NodePublicKey: Sendable, Hashable, Codable, RawRepresentable, CustomStringConvertible {
+ /// The text form, "nodekey:".
+ public let rawValue: String
+
+ /// Creates a key from its text form, or returns nil unless it is
+ /// "nodekey:" followed by exactly 64 hex digits.
+ public init?(rawValue: String) {
+ guard rawValue.hasPrefix("nodekey:") else { return nil }
+ let hex = rawValue.dropFirst("nodekey:".count)
+ guard hex.count == 64, hex.allSatisfy({ $0.isASCII && $0.isHexDigit }) else { return nil }
+ self.rawValue = rawValue
+ }
+
+ /// The text form.
+ public var description: String { rawValue }
+
+ /// Decodes the text form, validating it.
+ public init(from decoder: any Decoder) throws {
+ let raw = try decoder.singleValueContainer().decode(String.self)
+ guard let key = NodePublicKey(rawValue: raw) else {
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "not a nodekey: string"))
+ }
+ self = key
+ }
+
+ /// Encodes the text form.
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(rawValue)
+ }
+}
+
+/// A server's tailcat address (the "tc..." string a tailcat server
+/// announces), which names the server's keys and its relay.
+public struct TailcatAddress: Sendable, Hashable, Codable, RawRepresentable, CustomStringConvertible {
+ /// The address text.
+ public let rawValue: String
+
+ /// Creates an address from its text. Only the "tc" prefix is checked
+ /// here; parse() validates the rest.
+ public init?(rawValue: String) {
+ guard rawValue.hasPrefix("tc"), rawValue.count > 2, !rawValue.utf8.contains(0) else { return nil }
+ self.rawValue = rawValue
+ }
+
+ /// The address text.
+ public var description: String { rawValue }
+
+ /// Decodes the address text, checking its prefix.
+ public init(from decoder: any Decoder) throws {
+ let raw = try decoder.singleValueContainer().decode(String.self)
+ guard let address = TailcatAddress(rawValue: raw) else {
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "not a tc... address"))
+ }
+ self = address
+ }
+
+ /// Encodes the address text.
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(rawValue)
+ }
+
+ /// Parses public metadata locally. The JSON omits the pre-shared key.
+ public func parse() throws -> AddressInfo {
+ do {
+ let json = try rawValue.withCInput { input in
+ try CAPI.string { tc_address_parse(input, $0, $1) }
+ }
+ return try AddressInfo(json: Data(json.utf8))
+ } catch TailcatError.invalidArgument(let message) {
+ throw TailcatError.invalidAddress(message)
+ }
+ }
+
+ /// Embeds relay details, fetching the relay map if needed. Swift task
+ /// cancellation interrupts the lookup without freeing its buffers early.
+ public func resolved(derpMapURL: URL? = nil, timeout: Duration? = .seconds(10)) async throws -> TailcatAddress {
+ _ = try parse()
+ return try await Blocking.run(timeout: timeout) { token in
+ let text = try rawValue.withCInput { address in
+ try (derpMapURL?.absoluteString ?? "").withCInput { url in
+ try CAPI.string { tc_address_resolve(token, address, url, $0, $1) }
+ }
+ }
+ guard let result = TailcatAddress(rawValue: text) else {
+ throw TailcatError.internalError("C API returned an invalid address")
+ }
+ return result
+ }
+ }
+}
+
+/// The contents of a tailcat address.
+public struct AddressInfo: Sendable, Hashable {
+ /// The server's node public key.
+ public let serverPublicKey: NodePublicKey
+ /// The DERP map region the server uses, when the address references one
+ /// by ID; nil when it embeds the relay details instead.
+ public let regionID: Int?
+ /// The hostnames of the relays embedded in the address, in order; empty
+ /// when the address references a region by ID.
+ public let relayHosts: [String]
+ /// The full decoded address as JSON, public metadata from the C API.
+ public let json: Data
+
+ /// Decodes the JSON of tc_address_parse.
+ init(json: Data) throws {
+ let raw: RawAddress
+ do {
+ raw = try JSONDecoder().decode(RawAddress.self, from: json)
+ } catch {
+ throw TailcatError.internalError("decoding address JSON: \(error)")
+ }
+ guard let key = NodePublicKey(rawValue: raw.serverPublic) else {
+ throw TailcatError.invalidAddress("unexpected server public key \(raw.serverPublic)")
+ }
+ serverPublicKey = key
+ let hosts = (raw.region ?? []).flatMap { $0.nodes ?? [] }.compactMap { $0.hostName }.filter { !$0.isEmpty }
+ relayHosts = hosts
+ if let id = raw.regionID, id > 0, hosts.isEmpty {
+ regionID = id
+ } else {
+ regionID = nil
+ }
+ self.json = json
+ }
+
+ private struct RawAddress: Decodable {
+ var serverPublic: String
+ var regionID: Int?
+ var region: [RawRegion]?
+
+ enum CodingKeys: String, CodingKey {
+ case serverPublic = "public_key"
+ case regionID = "region_id"
+ case region = "regions"
+ }
+ }
+
+ private struct RawRegion: Decodable {
+ var nodes: [RawNode]?
+
+ enum CodingKeys: String, CodingKey {
+ case nodes = "Nodes"
+ }
+ }
+
+ private struct RawNode: Decodable {
+ var hostName: String?
+
+ enum CodingKeys: String, CodingKey {
+ case hostName = "HostName"
+ }
+ }
+}
diff --git a/swift/Sources/TailcatKit/TailcatClient.swift b/swift/Sources/TailcatKit/TailcatClient.swift
new file mode 100644
index 000000000..4fffb7352
--- /dev/null
+++ b/swift/Sources/TailcatKit/TailcatClient.swift
@@ -0,0 +1,105 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import Foundation
+
+public actor TailcatClient {
+ public nonisolated let address: TailcatAddress
+ private let handle: Handle
+ private let logger: any LogSink
+
+ /// Creates a client without network access. The first ping/dial starts it.
+ public init(address: TailcatAddress, identity: Identity? = nil, derpMapURL: URL? = nil,
+ logger: any LogSink = BlackholeLogger()) throws {
+ _ = try address.parse()
+ var config = ["address": address.rawValue]
+ if let identity { config["key"] = identity.privateKey }
+ if let derpMapURL { config["derp_map_url"] = derpMapURL.absoluteString }
+ let json = String(decoding: try JSONEncoder().encode(config), as: UTF8.self)
+ var raw: UInt64 = 0
+ try json.withCInput { input in
+ try CAPI.check { tc_client_new(input, &raw, $0) }
+ }
+ self.handle = Handle(raw)
+ self.address = address
+ self.logger = logger
+ logger.log("TailcatClient: created")
+ }
+
+ public var publicKey: NodePublicKey {
+ get async throws {
+ let raw = try handle.value()
+ return try await Blocking.run { token in
+ let info = try CAPI.info(raw, token)
+ guard let text = info.publicKey, let key = NodePublicKey(rawValue: text) else {
+ throw TailcatError.internalError("client info contained no public key")
+ }
+ return key
+ }
+ }
+ }
+
+ /// Measures relay round-trip latency. Zero is valid (sub-millisecond RTT).
+ public func ping(timeout: Duration? = .seconds(10)) async throws -> Duration {
+ let raw = try handle.value()
+ return try await Blocking.run(timeout: timeout) { token in
+ var latency: Int32 = 0
+ try CAPI.check { tc_client_ping(raw, token, &latency, $0) }
+ return .milliseconds(latency)
+ }
+ }
+
+ public func path(timeout: Duration? = .seconds(10)) async throws -> PathInfo {
+ let raw = try handle.value()
+ return try await Blocking.run(timeout: timeout) { token in
+ let json = try CAPI.string { tc_client_disco_ping(raw, token, $0, $1) }
+ return try PathInfo(json: Data(json.utf8))
+ }
+ }
+
+ public func connect(port: UInt16, timeout: Duration? = .seconds(15)) async throws -> Connection {
+ guard port != 0 else { throw TailcatError.invalidPort }
+ let raw = try handle.value()
+ return try await Blocking.run(timeout: timeout) { token in
+ var connection: UInt64 = 0
+ try CAPI.check { tc_client_dial(raw, token, port, Int32(TC_TCP), &connection, $0) }
+ return try Connection.adopt(connection, token: token)
+ }
+ }
+
+ public func drain(timeout: Duration = .seconds(5)) async throws {
+ let raw = try handle.value()
+ try await Blocking.run(timeout: timeout) { token in
+ try CAPI.check { tc_drain(raw, token, $0) }
+ }
+ }
+
+ /// Closes the client and its connections and waits for pending C calls.
+ public func close() async {
+ await handle.close()
+ logger.log("TailcatClient: closed")
+ }
+}
+
+public struct PathInfo: Sendable, Hashable {
+ public let isDirect: Bool
+ public let endpoint: String?
+ public let relayRegionCode: String?
+ public let latency: Duration
+ public let json: Data
+
+ init(json: Data) throws {
+ struct Result: Decodable {
+ var latency: Double
+ var endpoint: String
+ var derp_region_code: String
+ }
+ let raw = try JSONDecoder().decode(Result.self, from: json)
+ endpoint = raw.endpoint.isEmpty ? nil : raw.endpoint
+ isDirect = endpoint != nil
+ relayRegionCode = isDirect || raw.derp_region_code.isEmpty ? nil : raw.derp_region_code
+ latency = .seconds(raw.latency)
+ self.json = json
+ }
+}
diff --git a/swift/Sources/TailcatKit/TailcatError.swift b/swift/Sources/TailcatKit/TailcatError.swift
new file mode 100644
index 000000000..6378ef316
--- /dev/null
+++ b/swift/Sources/TailcatKit/TailcatError.swift
@@ -0,0 +1,99 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import Foundation
+
+/// Errors returned by TailcatKit. C errors are classified by ABI status code.
+public enum TailcatError: Error, Sendable, Equatable, CustomStringConvertible {
+ case invalidArgument(String)
+ case invalidAddress(String)
+ case invalidKey(String)
+ case closed
+ case timeout
+ case invalidPort
+ case internalError(String)
+
+ public var description: String {
+ switch self {
+ case .invalidArgument(let message): "invalid argument: \(message)"
+ case .invalidAddress(let message): "invalid tailcat address: \(message)"
+ case .invalidKey(let message): "invalid identity: \(message)"
+ case .closed: "closed"
+ case .timeout: "timed out"
+ case .invalidPort: "invalid port"
+ case .internalError(let message): message
+ }
+ }
+}
+
+/// A failed send that already handed bytes to the tunnel. Do not replay them.
+public struct PartialWriteError: Error, Sendable {
+ public let bytesWritten: Int
+ public let underlyingError: any Error
+}
+
+/// The only owner of error and string allocations crossing the C boundary.
+enum CAPI {
+ static func error(_ status: Int32, _ message: String?) -> (any Error)? {
+ switch Int(status) {
+ case TC_OK: nil
+ case TC_INVALID_ARGUMENT: TailcatError.invalidArgument(message ?? "invalid argument")
+ case TC_CLOSED: TailcatError.closed
+ case TC_TIMEOUT: TailcatError.timeout
+ case TC_CANCELLED: CancellationError()
+ default: TailcatError.internalError(message ?? "C API status \(status)")
+ }
+ }
+
+ static func check(_ body: (UnsafeMutablePointer?>) -> Int32) throws {
+ var message: UnsafeMutablePointer?
+ let status = body(&message)
+ if let error = error(status, take(message)) { throw error }
+ }
+
+ static func string(_ body: (UnsafeMutablePointer?>,
+ UnsafeMutablePointer?>) -> Int32) throws -> String {
+ var out: UnsafeMutablePointer?
+ defer { tc_free(out) }
+ try check { body(&out, $0) }
+ guard let out else { throw TailcatError.internalError("C API returned no string") }
+ return String(cString: out)
+ }
+
+ static func take(_ pointer: UnsafeMutablePointer?) -> String? {
+ guard let pointer else { return nil }
+ defer { tc_free(pointer) }
+ return String(cString: pointer)
+ }
+
+ static func info(_ handle: UInt64, _ token: UInt64) throws -> ResourceInfo {
+ let json = try string { tc_info(handle, token, $0, $1) }
+ return try JSONDecoder().decode(ResourceInfo.self, from: Data(json.utf8))
+ }
+}
+
+struct ResourceInfo: Decodable, Sendable {
+ var address: String?
+ var publicKey: String?
+ var localAddress: String?
+ var remoteAddress: String?
+
+ enum CodingKeys: String, CodingKey {
+ case address
+ case publicKey = "public_key"
+ case localAddress = "local_address"
+ case remoteAddress = "remote_address"
+ }
+
+ var localPort: UInt16? {
+ localAddress?.split(separator: ":").last.flatMap { UInt16($0) }
+ }
+}
+
+extension String {
+ // The C ABI borrows input strings and never modifies or retains them.
+ func withCInput(_ body: (UnsafeMutablePointer) throws -> T) rethrows -> T {
+ try withCString { try body(UnsafeMutablePointer(mutating: $0)) }
+ }
+}
diff --git a/swift/Sources/TailcatKit/TailcatServer.swift b/swift/Sources/TailcatKit/TailcatServer.swift
new file mode 100644
index 000000000..6ff7e7227
--- /dev/null
+++ b/swift/Sources/TailcatKit/TailcatServer.swift
@@ -0,0 +1,91 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import Foundation
+
+/// A server using the upstream C API. Listening starts it automatically.
+public actor TailcatServer {
+ public nonisolated let publicKey: NodePublicKey
+ public private(set) var address: TailcatAddress?
+ private let handle: Handle
+ private let logger: any LogSink
+
+ public init(configuration: ServerConfiguration = .init(), logger: any LogSink = BlackholeLogger()) throws {
+ let identity = try configuration.identity ?? Identity.generate()
+ var raw: UInt64 = 0
+ try configuration.json(identity: identity).withCInput { config in
+ try CAPI.check { tc_server_new(config, &raw, $0) }
+ }
+ self.handle = Handle(raw)
+ self.publicKey = identity.publicKey
+ self.logger = logger
+ logger.log("TailcatServer: created")
+ }
+
+ /// Starts the server and returns its secret address. Repeated starts succeed.
+ /// nil disables the deadline while preserving Swift task cancellation.
+ @discardableResult
+ public func start(timeout: Duration? = .seconds(30)) async throws -> TailcatAddress {
+ let raw = try handle.value()
+ let address = try await Blocking.run(timeout: timeout) { token in
+ try CAPI.check { tc_server_start(raw, token, $0) }
+ let info = try CAPI.info(raw, token)
+ guard let text = info.address, let address = TailcatAddress(rawValue: text) else {
+ throw TailcatError.internalError("server info contained no address")
+ }
+ return address
+ }
+ _ = try handle.value() // close may have run while this actor was suspended.
+ self.address = address
+ logger.log("TailcatServer: started")
+ return address
+ }
+
+ /// Starts the server if needed, then listens for TCP. Port 0 selects a free
+ /// port, available as Listener.port. There is no catch-all listener.
+ public func listen(on port: UInt16, timeout: Duration? = .seconds(30)) async throws -> Listener {
+ let raw = try handle.value()
+ let logger = logger
+ return try await Blocking.run(timeout: timeout) { token in
+ var listener: UInt64 = 0
+ try CAPI.check { tc_server_listen(raw, token, port, Int32(TC_TCP), &listener, $0) }
+ do {
+ let info = try CAPI.info(listener, token)
+ guard let port = info.localPort else {
+ throw TailcatError.internalError("listener info contained no port")
+ }
+ return Listener(handle: Handle(listener), port: port, logger: logger)
+ } catch {
+ _ = tc_close(listener, nil)
+ throw error
+ }
+ }
+ }
+
+ /// Restricts future registrations to the allowlist. Existing flows remain.
+ public func allow(_ key: NodePublicKey) async throws {
+ let raw = try handle.value()
+ try await Blocking.run { token in
+ try key.rawValue.withCInput { key in
+ try CAPI.check { tc_server_allow_client(raw, token, key, $0) }
+ }
+ }
+ }
+
+ /// Waits for TCP shutdown traffic before closing the peer. Use a finite limit.
+ public func drain(timeout: Duration = .seconds(5)) async throws {
+ let raw = try handle.value()
+ try await Blocking.run(timeout: timeout) { token in
+ try CAPI.check { tc_drain(raw, token, $0) }
+ }
+ }
+
+ /// Closes the server, listeners, and accepted connections. Waits off-thread
+ /// for their outstanding C calls to finish. Repeated closes are harmless.
+ public func close() async {
+ address = nil
+ await handle.close()
+ logger.log("TailcatServer: closed")
+ }
+}
diff --git a/swift/Tests/TailcatKitTests/AddressTests.swift b/swift/Tests/TailcatKitTests/AddressTests.swift
new file mode 100644
index 000000000..718f190b2
--- /dev/null
+++ b/swift/Tests/TailcatKitTests/AddressTests.swift
@@ -0,0 +1,115 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import Foundation
+import TailcatKit
+import XCTest
+
+/// Offline tests of TailcatAddress, AddressInfo and NodePublicKey.
+@MainActor
+final class AddressTests: XCTestCase {
+ /// The address in the README, referencing DERP region 302.
+ static let readmeAddress = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu"
+ /// The same server's resolved address, with the relay embedded.
+ static let resolvedAddress = "tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA"
+ static let readmeKey = "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34"
+
+ func testParseReadmeAddress() throws {
+ let address = try XCTUnwrap(TailcatAddress(rawValue: Self.readmeAddress))
+ XCTAssertEqual(address.rawValue, Self.readmeAddress)
+ XCTAssertEqual(address.description, Self.readmeAddress)
+ let info = try address.parse()
+ XCTAssertEqual(info.serverPublicKey.rawValue, Self.readmeKey)
+ XCTAssertEqual(info.regionID, 302)
+ XCTAssertEqual(info.relayHosts, [])
+ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: info.json) as? [String: Any])
+ XCTAssertEqual(json["region_id"] as? Int, 302)
+ XCTAssertEqual(json["public_key"] as? String, Self.readmeKey)
+ }
+
+ func testParseResolvedAddress() throws {
+ let address = try XCTUnwrap(TailcatAddress(rawValue: Self.resolvedAddress))
+ let info = try address.parse()
+ XCTAssertEqual(info.serverPublicKey.rawValue, Self.readmeKey)
+ XCTAssertNil(info.regionID)
+ XCTAssertEqual(info.relayHosts, ["tc302a.ipn.dev"])
+ }
+
+ func testInvalidPrefixIsNil() {
+ XCTAssertNil(TailcatAddress(rawValue: "nope"))
+ XCTAssertNil(TailcatAddress(rawValue: ""))
+ XCTAssertNil(TailcatAddress(rawValue: "tc"))
+ XCTAssertNotNil(TailcatAddress(rawValue: "tcx"))
+ }
+
+ func testGarbageThrowsInvalidAddress() throws {
+ let address = try XCTUnwrap(TailcatAddress(rawValue: "tcgarbage"))
+ XCTAssertThrowsError(try address.parse()) { error in
+ guard case TailcatError.invalidAddress(let message) = error else {
+ return XCTFail("unexpected error \(error)")
+ }
+ XCTAssertFalse(message.isEmpty)
+ }
+ }
+
+ func testAddressCodable() throws {
+ let address = try XCTUnwrap(TailcatAddress(rawValue: Self.readmeAddress))
+ let encoded = try JSONEncoder().encode([address])
+ XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "[\"\(Self.readmeAddress)\"]")
+ let decoded = try JSONDecoder().decode([TailcatAddress].self, from: encoded)
+ XCTAssertEqual(decoded, [address])
+ XCTAssertThrowsError(try JSONDecoder().decode(TailcatAddress.self, from: Data("\"nope\"".utf8)))
+ }
+
+ func testNodePublicKeyValidation() throws {
+ let key = try XCTUnwrap(NodePublicKey(rawValue: Self.readmeKey))
+ XCTAssertEqual(key.rawValue, Self.readmeKey)
+ XCTAssertEqual(key.description, Self.readmeKey)
+ XCTAssertNil(NodePublicKey(rawValue: "9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34"))
+ XCTAssertNil(NodePublicKey(rawValue: "nodekey:9c8d2e67"))
+ XCTAssertNil(NodePublicKey(rawValue: "nodekey:" + String(repeating: "g", count: 64)))
+ XCTAssertNotNil(NodePublicKey(rawValue: "nodekey:" + String(repeating: "A", count: 64)))
+ let encoded = try JSONEncoder().encode([key])
+ XCTAssertEqual(String(decoding: encoded, as: UTF8.self), "[\"\(Self.readmeKey)\"]")
+ XCTAssertEqual(try JSONDecoder().decode([NodePublicKey].self, from: encoded), [key])
+ }
+
+ /// A DERP map that cannot be fetched is a network failure, not a bad
+ /// address: only a malformed address is reported as invalidAddress.
+ func testResolveMapsNetworkFailures() async throws {
+ // Nothing listens on port 9 of the loopback interface, so the
+ // fetch is refused outright.
+ let refused = try XCTUnwrap(URL(string: "http://127.0.0.1:9/derpmap.json"))
+ let address = try XCTUnwrap(TailcatAddress(rawValue: Self.readmeAddress))
+ do {
+ _ = try await address.resolved(derpMapURL: refused, timeout: .seconds(5))
+ XCTFail("resolving against a refused DERP map URL succeeded")
+ } catch TailcatError.invalidAddress(let message) {
+ XCTFail("a network failure was reported as an invalid address: \(message)")
+ } catch let error as TailcatError {
+ guard case .internalError = error else {
+ return XCTFail("unexpected network error: \(error)")
+ }
+ }
+ // A malformed address is invalid whatever the map.
+ let garbage = try XCTUnwrap(TailcatAddress(rawValue: "tcgarbage"))
+ do {
+ _ = try await garbage.resolved(derpMapURL: refused, timeout: .seconds(5))
+ XCTFail("resolving a malformed address succeeded")
+ } catch TailcatError.invalidAddress {
+ }
+ // An address that embeds its relay resolves to itself, offline.
+ let embedded = try XCTUnwrap(TailcatAddress(rawValue: Self.resolvedAddress))
+ let same = try await embedded.resolved(derpMapURL: refused, timeout: .seconds(5))
+ XCTAssertEqual(same, embedded)
+ }
+
+ func testClientRejectsMalformedAddress() throws {
+ let address = try XCTUnwrap(TailcatAddress(rawValue: "tcgarbage"))
+ XCTAssertThrowsError(try TailcatClient(address: address)) { error in
+ guard case TailcatError.invalidAddress = error else {
+ return XCTFail("unexpected error \(error)")
+ }
+ }
+ }
+}
diff --git a/swift/Tests/TailcatKitTests/EndToEndTests.swift b/swift/Tests/TailcatKitTests/EndToEndTests.swift
new file mode 100644
index 000000000..3a8b8e834
--- /dev/null
+++ b/swift/Tests/TailcatKitTests/EndToEndTests.swift
@@ -0,0 +1,289 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import Foundation
+import Testing
+import os
+@testable import TailcatKit
+
+/// make test supplies a local DERP/STUN relay, so these tests need no public
+/// service. Running swift test directly still exercises all offline value tests.
+// Serialize transport tests so the worker saturation regression cannot consume
+// the short deadlines used by other tests running alongside it.
+@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["TAILCAT_TEST_REGION"] != nil))
+struct LocalRelayTests {
+ static func server() throws -> TailcatServer {
+ let json = ProcessInfo.processInfo.environment["TAILCAT_TEST_REGION"]!
+ return try TailcatServer(configuration: .init(relay: .custom(Data(json.utf8))))
+ }
+
+ static func withPair(_ body: @Sendable (TailcatServer, TailcatClient, Listener, Connection, Connection) async throws -> Void) async throws {
+ let server = try server()
+ let listener: Listener
+ let client: TailcatClient
+ do {
+ listener = try await server.listen(on: 0)
+ let address = try await server.start()
+ client = try TailcatClient(address: address, derpMapURL: URL(string: "none"))
+ } catch { await server.close(); throw error }
+ do {
+ let outgoing = try await client.connect(port: listener.port)
+ let incoming = try await listener.accept(timeout: .seconds(10))
+ try await body(server, client, listener, outgoing, incoming)
+ } catch {
+ await client.close(); await server.close()
+ throw error
+ }
+ await client.close(); await server.close()
+ }
+
+ @Test func roundTripHalfCloseAndOwnership() async throws {
+ try await Self.withPair { server, client, listener, outgoing, incoming in
+ #expect(listener.port > 0)
+ #expect(incoming.localPort == listener.port)
+ #expect(outgoing.remoteAddress != nil)
+ #expect(try await server.start() == server.address)
+ let address = try #require(await server.address)
+ #expect(try address.parse().serverPublicKey == server.publicKey)
+ #expect(try await client.ping() >= .zero)
+ let path = try await client.path()
+ #expect(path.isDirect || path.relayRegionCode != nil)
+ // Accepted streams survive listener closure.
+ await listener.close()
+ try await outgoing.send(Data("hello".utf8))
+ try await outgoing.closeWrite()
+ var request = Data()
+ for try await bytes in incoming.incoming { request.append(bytes) }
+ #expect(request == Data("hello".utf8))
+ try await incoming.send(Data("WORLD".utf8))
+ try await incoming.closeWrite()
+ var reply = Data()
+ for try await bytes in outgoing.incoming { reply.append(bytes) }
+ #expect(reply == Data("WORLD".utf8))
+ #expect(try await outgoing.receive().isEmpty)
+ await outgoing.close(); await incoming.close()
+ try await client.drain()
+ }
+ }
+
+ @Test func receiveCancellationTimeoutAndReuse() async throws {
+ try await Self.withPair { _, _, _, outgoing, incoming in
+ for index in 0..<60 {
+ let waiting = Task { try await incoming.receive() }
+ if index % 3 == 1 { await Task.yield() }
+ if index % 3 == 2 { try await Task.sleep(for: .milliseconds(1)) }
+ waiting.cancel()
+ do { _ = try await waiting.value; Issue.record("cancelled read succeeded") }
+ catch { #expect(error is CancellationError) }
+ }
+ do {
+ _ = try await incoming.receive(timeout: .milliseconds(20))
+ Issue.record("idle read did not time out")
+ } catch { #expect(error as? TailcatError == .timeout) }
+ try await outgoing.send(Data("abcdef".utf8))
+ #expect(try await incoming.receive(maxLength: 2) == Data("ab".utf8))
+ #expect(try await incoming.receive(maxLength: 4) == Data("cdef".utf8))
+ }
+ }
+
+ @Test func overlappingReadIsRejectedWithoutDisturbingFirst() async throws {
+ try await Self.withPair { _, _, _, outgoing, incoming in
+ let first = Task { try await incoming.receive() }
+ try await Task.sleep(for: .milliseconds(20))
+ do {
+ _ = try await incoming.receive(timeout: .milliseconds(10))
+ Issue.record("overlapping receive succeeded")
+ } catch {
+ guard case TailcatError.invalidArgument = error else { throw error }
+ }
+ try await outgoing.send(Data("a".utf8))
+ #expect(try await first.value == Data("a".utf8))
+ }
+ }
+
+ @Test func closeWakesReadAndRetiresChildren() async throws {
+ try await Self.withPair { server, _, listener, _, incoming in
+ let reading = Task { try await incoming.receive() }
+ let accepting = Task { try await listener.accept() }
+ try await Task.sleep(for: .milliseconds(20))
+ await server.close()
+ do { _ = try await reading.value; Issue.record("read after server close succeeded") }
+ catch { #expect(error as? TailcatError == .closed) }
+ do { _ = try await accepting.value; Issue.record("accept after server close succeeded") }
+ catch { #expect(error as? TailcatError == .closed) }
+ await incoming.close(); await incoming.close()
+ }
+ }
+
+ @Test(arguments: [false, true])
+ func shutdownWithManyIdleConnections(deinitializing: Bool) async throws {
+ var server: TailcatServer? = try Self.server()
+ var client: TailcatClient?
+ do {
+ let listener = try await server!.listen(on: 0)
+ let address = try await server!.start()
+ let peer = try TailcatClient(address: address, derpMapURL: URL(string: "none"))
+ client = peer
+ var incoming: [Connection] = []
+ var outgoing: [Connection] = []
+ for _ in 0..<96 {
+ outgoing.append(try await peer.connect(port: listener.port))
+ incoming.append(try await listener.accept(timeout: .seconds(5)))
+ }
+ let started = OSAllocatedUnfairLock(initialState: 0)
+ let reads = incoming.map { connection in
+ Task {
+ started.withLock { $0 += 1 }
+ do { _ = try await connection.receive(); return false }
+ catch { return error as? TailcatError == .closed }
+ }
+ }
+ // Give the nil-deadline reads time to occupy the Dispatch workers.
+ while started.withLock({ $0 }) < reads.count { await Task.yield() }
+ try await Task.sleep(for: .milliseconds(300))
+ // Rescue the test if shutdown regresses; its reads must be woken
+ // by close, not by a deadline or this independent cancellation.
+ let watchdog = Task {
+ do { try await Task.sleep(for: .seconds(5)) } catch { return }
+ Issue.record("shutdown stalled behind idle reads")
+ for read in reads { read.cancel() }
+ }
+ defer { watchdog.cancel() }
+ if deinitializing {
+ server = nil
+ } else {
+ await server!.close()
+ }
+ var allClosed = true
+ for read in reads {
+ let closed = await read.value
+ allClosed = allClosed && closed
+ }
+ #expect(allClosed)
+ withExtendedLifetime(outgoing) {}
+ } catch {
+ await client?.close()
+ await server?.close()
+ throw error
+ }
+ await client?.close()
+ await server?.close()
+ }
+
+ @Test func acceptCancellationTimeoutAndReuse() async throws {
+ let server = try Self.server()
+ do {
+ let listener = try await server.listen(on: 0)
+ for index in 0..<30 {
+ let waiting = Task { try await listener.accept() }
+ if index % 2 == 1 { try await Task.sleep(for: .milliseconds(1)) }
+ waiting.cancel()
+ do { _ = try await waiting.value; Issue.record("cancelled accept succeeded") }
+ catch { #expect(error is CancellationError) }
+ }
+ do {
+ _ = try await listener.accept(timeout: .milliseconds(20))
+ Issue.record("idle accept did not time out")
+ } catch { #expect(error as? TailcatError == .timeout) }
+ let address = try await server.start()
+ let client = try TailcatClient(address: address, derpMapURL: URL(string: "none"))
+ do {
+ let outgoing = try await client.connect(port: listener.port)
+ let incoming = try await listener.accept(timeout: .seconds(5))
+ await outgoing.close(); await incoming.close()
+ } catch { await client.close(); throw error }
+ await client.close()
+ let waiting = Task { try await listener.accept() }
+ await listener.close()
+ do { _ = try await waiting.value; Issue.record("accept after close succeeded") }
+ catch { #expect(error as? TailcatError == .closed) }
+ let again = try await server.listen(on: listener.port)
+ #expect(again.port == listener.port)
+ } catch { await server.close(); throw error }
+ await server.close()
+ }
+
+ @Test func largeSendAndPartialWriteTimeout() async throws {
+ try await Self.withPair { _, _, _, outgoing, incoming in
+ let payload = Data(repeating: 42, count: 1_000_000)
+ let reading = Task {
+ var received = Data()
+ while received.count < payload.count {
+ let bytes = try await incoming.receive(timeout: .seconds(10))
+ if bytes.isEmpty { break }
+ received.append(bytes)
+ }
+ return received
+ }
+ try await outgoing.send(payload, timeout: .seconds(10))
+ #expect(try await reading.value == payload)
+ do {
+ // No receiver drains this write, so TCP backpressure exceeds
+ // the deadline after a prefix has been transmitted.
+ try await outgoing.send(Data(repeating: 7, count: 16_000_000), timeout: .milliseconds(100))
+ Issue.record("backpressured write succeeded")
+ } catch let error as PartialWriteError {
+ #expect(error.bytesWritten > 0)
+ #expect(error.bytesWritten < 16_000_000)
+ #expect(error.underlyingError as? TailcatError == .timeout)
+ }
+ }
+ }
+
+ @Test func sendCancellationAndOverlappingWrites() async throws {
+ try await Self.withPair { _, _, _, outgoing, incoming in
+ let sending = Task { try await outgoing.send(Data(repeating: 9, count: 16_000_000)) }
+ // Receiving the first byte proves the large send is in flight.
+ #expect(try await incoming.receive(maxLength: 1, timeout: .seconds(5)) == Data([9]))
+ do {
+ try await outgoing.send(Data([10]))
+ Issue.record("overlapping send succeeded")
+ } catch {
+ guard case TailcatError.invalidArgument = error else { throw error }
+ }
+ do {
+ try await outgoing.closeWrite()
+ Issue.record("overlapping half-close succeeded")
+ } catch {
+ guard case TailcatError.invalidArgument = error else { throw error }
+ }
+ sending.cancel()
+ var bytesWritten = 0
+ do { try await sending.value; Issue.record("cancelled send succeeded") }
+ catch let error as PartialWriteError {
+ bytesWritten = error.bytesWritten
+ #expect(error.underlyingError is CancellationError)
+ }
+ #expect(bytesWritten > 0 && bytesWritten < 16_000_000)
+ var received = 1
+ while received < bytesWritten {
+ let chunk = try await incoming.receive(maxLength: min(65_536, bytesWritten - received), timeout: .seconds(5))
+ #expect(!chunk.isEmpty)
+ if chunk.isEmpty { break }
+ received += chunk.count
+ }
+ #expect(received == bytesWritten)
+ try await outgoing.send(Data([11]))
+ #expect(try await incoming.receive(maxLength: 1, timeout: .seconds(5)) == Data([11]))
+ }
+ }
+
+ @Test func clientIdentityAndExpiredOperations() async throws {
+ let server = try Self.server()
+ do {
+ let address = try await server.start()
+ let identity = try Identity.generate()
+ let client = try TailcatClient(address: address, identity: identity, derpMapURL: URL(string: "none"))
+ do {
+ #expect(try await client.publicKey == identity.publicKey)
+ do { _ = try await client.connect(port: 0); Issue.record("zero dial port accepted") }
+ catch { #expect(error as? TailcatError == .invalidPort) }
+ do { _ = try await client.ping(timeout: .zero); Issue.record("expired ping succeeded") }
+ catch { #expect(error as? TailcatError == .timeout) }
+ #expect(try await client.ping() >= .zero)
+ } catch { await client.close(); throw error }
+ await client.close()
+ } catch { await server.close(); throw error }
+ await server.close()
+ }
+}
diff --git a/swift/Tests/TailcatKitTests/IdentityTests.swift b/swift/Tests/TailcatKitTests/IdentityTests.swift
new file mode 100644
index 000000000..9286452d5
--- /dev/null
+++ b/swift/Tests/TailcatKitTests/IdentityTests.swift
@@ -0,0 +1,45 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import Foundation
+import Testing
+import TailcatKit
+
+struct IdentityTests {
+ @Test func generateAndRoundTrip() throws {
+ let identity = try Identity.generate()
+ let json = try #require(JSONSerialization.jsonObject(with: Data(identity.json.utf8)) as? [String: String])
+ #expect(json["private_key"]?.hasPrefix("privkey:") == true)
+ #expect(json["preshared_key"] != nil)
+ #expect(json["public_key"] == identity.publicKey.rawValue)
+ #expect(try Identity.generate().publicKey != identity.publicKey)
+ #expect(try Identity(json: identity.json) == identity)
+ #expect(try JSONDecoder().decode(Identity.self, from: JSONEncoder().encode(identity)) == identity)
+ }
+
+ @Test(arguments: ["{}", "not json", "{\"Private\":\"old CLI format\"}"])
+ func rejectsInvalidBundle(_ json: String) {
+ #expect(throws: TailcatError.self) { try Identity(json: json) }
+ }
+
+ @Test func rejectsMismatchedPublicKey() throws {
+ let identity = try Identity.generate()
+ var json = try #require(JSONSerialization.jsonObject(with: Data(identity.json.utf8)) as? [String: String])
+ json["public_key"] = try Identity.generate().publicKey.rawValue
+ #expect(throws: TailcatError.self) {
+ try Identity(json: String(decoding: JSONEncoder().encode(json), as: UTF8.self))
+ }
+ }
+
+ @Test func rejectsInvalidSecretWithoutEchoingIt() throws {
+ let identity = try Identity.generate()
+ var json = try #require(JSONSerialization.jsonObject(with: Data(identity.json.utf8)) as? [String: String])
+ json["private_key"] = "invalid-secret-value"
+ do {
+ _ = try Identity(json: String(decoding: JSONEncoder().encode(json), as: UTF8.self))
+ Issue.record("invalid key accepted")
+ } catch {
+ #expect(!String(describing: error).contains("invalid-secret-value"))
+ }
+ }
+}
diff --git a/swift/Tests/TailcatKitTests/OfflineTests.swift b/swift/Tests/TailcatKitTests/OfflineTests.swift
new file mode 100644
index 000000000..0c7643ea5
--- /dev/null
+++ b/swift/Tests/TailcatKitTests/OfflineTests.swift
@@ -0,0 +1,76 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+import CTailcat
+import Foundation
+import Testing
+@testable import TailcatKit
+
+struct OfflineTests {
+ @Test func ABIAndErrors() throws {
+ #expect(tc_abi_version() == 1)
+ let json = try CAPI.string { tc_build_info($0, $1) }
+ #expect(try JSONSerialization.jsonObject(with: Data(json.utf8)) is [String: Any])
+ // Status codes, not message content, decide the Swift error.
+ #expect(CAPI.error(Int32(TC_TIMEOUT), "anything") as? TailcatError == .timeout)
+ #expect(CAPI.error(Int32(TC_CLOSED), "timeout") as? TailcatError == .closed)
+ #expect(CAPI.error(Int32(TC_FAILURE), "timeout") as? TailcatError == .internalError("timeout"))
+ #expect(CAPI.error(Int32(TC_CANCELLED), nil) is CancellationError)
+ }
+
+ @Test func timeouts() {
+ #expect(Duration.zero.nanosecondsForC == 0)
+ #expect(Duration.seconds(-1).nanosecondsForC == 0)
+ #expect(Duration.nanoseconds(1).nanosecondsForC == 1)
+ #expect(Duration(secondsComponent: 0, attosecondsComponent: 1).nanosecondsForC == 1)
+ #expect(Duration.milliseconds(1500).nanosecondsForC == 1_500_000_000)
+ #expect(Duration.seconds(Int64.max).nanosecondsForC == Int64.max)
+ #expect((Duration.seconds(9_223_372_036) + .nanoseconds(854_775_808)).nanosecondsForC == Int64.max)
+ }
+
+ @Test func preCancelledOperationDoesNotRun() async throws {
+ let task = Task {
+ withUnsafeCurrentTask { $0?.cancel() }
+ return try await Blocking.run { _ in
+ Issue.record("cancelled task entered C operation")
+ return 1
+ }
+ }
+ do { _ = try await task.value; Issue.record("cancelled operation succeeded") }
+ catch { #expect(error is CancellationError) }
+ }
+
+ @Test func serverBeforeStartAndAfterClose() async throws {
+ let identity = try Identity.generate()
+ let server = try TailcatServer(configuration: .init(identity: identity, relay: .region(302)))
+ #expect(server.publicKey == identity.publicKey)
+ #expect(await server.address == nil)
+ try await server.allow(try Identity.generate().publicKey)
+ try await server.drain()
+ await server.close()
+ await server.close()
+ do { _ = try await server.start(); Issue.record("start after close succeeded") }
+ catch { #expect(error as? TailcatError == .closed) }
+ }
+
+ @Test(arguments: [0, -1, Int(Int32.max) + 1])
+ func rejectsInvalidRegion(_ id: Int) {
+ #expect(throws: TailcatError.self) { try TailcatServer(configuration: .init(relay: .region(id))) }
+ }
+
+ @Test func rejectsInvalidCustomRegion() {
+ #expect(throws: (any Error).self) {
+ try TailcatServer(configuration: .init(relay: .custom(Data("[]".utf8))))
+ }
+ }
+
+ @Test func pathMetadata() throws {
+ let direct = try PathInfo(json: Data("{\"latency\":0.001,\"endpoint\":\"127.0.0.1:1234\",\"derp_region_code\":\"test\"}".utf8))
+ #expect(direct.isDirect)
+ #expect(direct.relayRegionCode == nil)
+ #expect(direct.latency == .milliseconds(1))
+ let relay = try PathInfo(json: Data("{\"latency\":0,\"endpoint\":\"\",\"derp_region_code\":\"test\"}".utf8))
+ #expect(!relay.isDirect)
+ #expect(relay.relayRegionCode == "test")
+ }
+}
diff --git a/swift/include/module.modulemap b/swift/include/module.modulemap
new file mode 100644
index 000000000..c2a81e83d
--- /dev/null
+++ b/swift/include/module.modulemap
@@ -0,0 +1,4 @@
+module CTailcat {
+ header "tailcat.h"
+ export *
+}
diff --git a/swift/script/clangwrap-ios-sim-arm.sh b/swift/script/clangwrap-ios-sim-arm.sh
new file mode 100755
index 000000000..9ce446cee
--- /dev/null
+++ b/swift/script/clangwrap-ios-sim-arm.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Copyright (c) Tailscale Inc & contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+# cgo CC wrapper for the iOS simulator on Apple silicon (arm64).
+
+SDK=iphonesimulator
+PLATFORM=ios-simulator
+
+CLANGARCH=arm64
+
+SDK_PATH=`xcrun --sdk $SDK --show-sdk-path`
+
+# cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang.
+CLANG=`xcrun --sdk $SDK --find clang`
+
+exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=17.0 "$@"
diff --git a/swift/script/clangwrap-ios-sim-x86.sh b/swift/script/clangwrap-ios-sim-x86.sh
new file mode 100755
index 000000000..824a7db9d
--- /dev/null
+++ b/swift/script/clangwrap-ios-sim-x86.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Copyright (c) Tailscale Inc & contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+# cgo CC wrapper for the iOS simulator on Intel Macs (x86_64).
+
+SDK=iphonesimulator
+PLATFORM=ios-simulator
+
+CLANGARCH=x86_64
+
+SDK_PATH=`xcrun --sdk $SDK --show-sdk-path`
+
+# cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang.
+CLANG=`xcrun --sdk $SDK --find clang`
+
+exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=17.0 "$@"
diff --git a/swift/script/clangwrap-ios.sh b/swift/script/clangwrap-ios.sh
new file mode 100755
index 000000000..cd9d7b90a
--- /dev/null
+++ b/swift/script/clangwrap-ios.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Copyright (c) Tailscale Inc & contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+# cgo CC wrapper for iOS devices (arm64).
+
+SDK=iphoneos
+PLATFORM=ios
+
+CLANGARCH="arm64"
+
+SDK_PATH=`xcrun --sdk $SDK --show-sdk-path`
+
+# cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang.
+CLANG=`xcrun --sdk $SDK --find clang`
+
+exec "$CLANG" -arch $CLANGARCH -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=17.0 "$@"
diff --git a/swift/script/clangwrap-macos-x86.sh b/swift/script/clangwrap-macos-x86.sh
new file mode 100755
index 000000000..ee1f25051
--- /dev/null
+++ b/swift/script/clangwrap-macos-x86.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+# Copyright (c) Tailscale Inc & contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+# cgo CC wrapper for cross-compiling the macOS x86_64 slice on any Mac.
+# MACOS_TARGET, exported by the Makefile, is the minimum macOS version.
+
+SDK=macosx
+
+SDK_PATH=`xcrun --sdk $SDK --show-sdk-path`
+
+# cmd/cgo doesn't support llvm-gcc-4.2, so we have to use clang.
+CLANG=`xcrun --sdk $SDK --find clang`
+
+exec "$CLANG" -arch x86_64 -target x86_64-apple-macos${MACOS_TARGET:-14.0} -isysroot "$SDK_PATH" "$@"
diff --git a/swift/testdata/relay_test.go b/swift/testdata/relay_test.go
new file mode 100644
index 000000000..ac9a15287
--- /dev/null
+++ b/swift/testdata/relay_test.go
@@ -0,0 +1,31 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+package swift_test
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "testing"
+
+ "tailscale.com/tstest/integration"
+ "tailscale.com/types/logger"
+)
+
+// TestSwift keeps a local DERP/STUN fixture alive for the Swift test process.
+// testdata is excluded from go test ./..., so this runs only via make -C swift test.
+func TestSwift(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)
+ }
+ cmd := exec.CommandContext(t.Context(), "swift", "test", "-Xswiftc", "-warnings-as-errors")
+ cmd.Dir = ".."
+ cmd.Env = append(os.Environ(), "TAILCAT_TEST_REGION="+string(region))
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ if err := cmd.Run(); err != nil {
+ t.Fatal(err)
+ }
+}