Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions swift/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Built by `make xcframework` in this directory.
/CTailcat.xcframework
# SwiftPM, xcodebuild (-derivedDataPath build) and Xcode state.
/.build
/build
/.swiftpm
77 changes: 77 additions & 0 deletions swift/Makefile
Original file line number Diff line number Diff line change
@@ -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'
36 changes: 36 additions & 0 deletions swift/Package.swift
Original file line number Diff line number Diff line change
@@ -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]
)
174 changes: 174 additions & 0 deletions swift/README.md
Original file line number Diff line number Diff line change
@@ -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 <address> 7777
swift run tailcat-demo parse <address>
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 <address> 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
```
Loading