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) + } +}