diff --git a/.devcontainer/swift/Dockerfile b/.devcontainer/swift/Dockerfile new file mode 100644 index 0000000..11cc874 --- /dev/null +++ b/.devcontainer/swift/Dockerfile @@ -0,0 +1,22 @@ +FROM swift:6.3 + +ARG GO_VERSION=1.25.5 +ARG TARGETARCH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + build-essential \ + libcurl4-openssl-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH:-amd64}.tar.gz" -o /tmp/go.tar.gz \ + && tar -C /usr/local -xzf /tmp/go.tar.gz \ + && rm /tmp/go.tar.gz + +ENV PATH="/usr/local/go/bin:${PATH}" +ENV GOPATH="/go" +ENV PATH="${GOPATH}/bin:${PATH}" + +RUN go version && swift --version diff --git a/.devcontainer/swift/devcontainer.json b/.devcontainer/swift/devcontainer.json new file mode 100644 index 0000000..02a2d97 --- /dev/null +++ b/.devcontainer/swift/devcontainer.json @@ -0,0 +1,23 @@ +{ + "name": "swift", + "build": { + "dockerfile": "Dockerfile", + "context": "../.." + }, + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", + "customizations": { + "vscode": { + "extensions": [ + "swiftlang.swift-vscode", + "golang.go" + ], + "settings": { + "[swift]": { + "editor.formatOnSave": false + } + } + } + }, + "postCreateCommand": "go version && swift --version" +} diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 0a3afb8..2551d70 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -3,7 +3,7 @@ name: swift on: [push] jobs: - build: + build-macos: runs-on: macos-15 steps: @@ -17,8 +17,28 @@ jobs: - name: Test run: cd swift && make test - - name: iOS + - name: iOS run: cd swift && make ios-fat - name: macos run: cd swift && make macos + + build-linux: + + runs-on: ubuntu-latest + container: swift:6.3 + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25.5" + + - name: Install cross toolchains + run: | + apt-get update + apt-get install -y --no-install-recommends build-essential gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu libc6-dev-amd64-cross libc6-dev-arm64-cross + + - name: Test + run: cd swift && make test-spm diff --git a/.gitignore b/.gitignore index 49fb623..fde5bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ libtailscale.bundle /sourcepkg/libtailscale.tar* /vendor/ + +.build/ \ No newline at end of file diff --git a/swift/Examples/TailscaleKitCLI/Makefile b/swift/Examples/TailscaleKitCLI/Makefile new file mode 100644 index 0000000..ebfa8a3 --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Makefile @@ -0,0 +1,14 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause + +.PHONY: spm-setup +spm-setup: ## Build the TailscaleKit artifact bundle this example depends on (Linux and macOS; see swift/README.md) + @$(MAKE) -C ../.. spm-setup + +.PHONY: help +help: ## Show this help + @echo "\nSpecify a command. The choices are:\n" + @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-12s\033[m %s\n", $$1, $$2}' + @echo "" + +.DEFAULT_GOAL := help diff --git a/swift/Examples/TailscaleKitCLI/Package.resolved b/swift/Examples/TailscaleKitCLI/Package.resolved new file mode 100644 index 0000000..b960c1f --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "59e434ccdf862e4f79fe7b34e3a584141df1df45722582e843cad945bedff4e6", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + } + ], + "version" : 3 +} diff --git a/swift/Examples/TailscaleKitCLI/Package.swift b/swift/Examples/TailscaleKitCLI/Package.swift new file mode 100644 index 0000000..1e01072 --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:6.3 +import PackageDescription + +let package = Package( + name: "TailscaleKitCLI", + dependencies: [ + .package(name: "TailscaleKit", path: "../.."), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.0"), + ], + targets: [ + .executableTarget( + name: "tsdemo", + dependencies: [ + .product(name: "TailscaleKit", package: "TailscaleKit"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ) + ] +) diff --git a/swift/Examples/TailscaleKitCLI/README.md b/swift/Examples/TailscaleKitCLI/README.md new file mode 100644 index 0000000..7c617bc --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/README.md @@ -0,0 +1,71 @@ +# TailscaleKitCLI + +A minimal SwiftPM executable, built with [swift-argument-parser](https://github.com/apple/swift-argument-parser), that joins your tailnet and lists its devices using TailscaleKit. Needs no Xcode project — it exists purely as a proof that TailscaleKit works from plain SwiftPM, including on Linux. + +Unlike `TailscaleKitHello` (an Xcode app that links a prebuilt `.xcframework`), this package depends directly on `swift/Package.swift`, exercising the same SwiftPM path covered by `swift test`/`make test-spm`. + +## Setup + +From this directory, build the artifact bundle this package needs. Run this once: + +```sh +$ make spm-setup +``` + +Generate and export an auth key at https://login.tailscale.com/admin/settings/keys. + +```sh +$ export TS_AUTHKEY=tskey-auth-... +``` + +## Usage + +```sh +$ swift run tsdemo +``` + +Example output: + +``` +make spm-setup +make[1]: Entering directory '/workspace/swift' + +::: Building SwiftPM artifact bundle for TailscaleKit ::: +./script/build-artifactbundle.sh +::: Building libtailscale.a for x86_64-unknown-linux-gnu (GOOS=linux GOARCH=amd64, CC=x86_64-linux-gnu-gcc) ::: +::: Building libtailscale.a for aarch64-unknown-linux-gnu (GOOS=linux GOARCH=arm64, CC=aarch64-linux-gnu-gcc) ::: +::: Skipping macOS variants (not running on a Darwin host) ::: +wrote /workspace/swift/build/TailscaleKit.artifactbundle +make[1]: Leaving directory '/workspace/swift' +root@82cf0493d6f1:/workspace/swift/Examples/TailscaleKitCLI# export TS_AUTHKEY=tskey-auth-... +root@82cf0493d6f1:/workspace/swift/Examples/TailscaleKitCLI# swift run tsdemo +[1/1] Planning build +Building for debugging... +clang: warning: argument unused during compilation: '-F/workspace/swift/Examples/TailscaleKitCLI/.build/aarch64-unknown-linux-gnu/debug' [-Wunused-command-line-argument] +[40/40] Linking tsdemo +Build of product 'tsdemo' complete! (2.97s) +2026/07/11 21:14:20 tsnet running state path /tmp/tsdemo-FF27454D/tailscaled.state +2026/07/11 21:14:20 tsnet starting with hostname "tsdemo-FF27454D", varRoot "/tmp/tsdemo-FF27454D" +2026/07/11 21:14:20 LocalBackend state is NeedsLogin; running StartLoginInteractive... +Bringing up "tsdemo-FF27454D", waiting to associate with the tailnet... +2026/07/11 21:14:22 localapi tcp serve error: use of closed network connection +2026/07/11 21:14:22 socks5: SOCKS5 server exited: use of closed network connection +Backend state: Running +Devices on this tailnet: + [offline] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [online ] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [online ] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [offline] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [online ] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [online ] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. + [offline] xxx 100.xx.xx.xx xxx.my-tailnet-name.ts.net. +``` + +Brings up a throwaway, ephemeral in-process tsnet node, then prints the tailnet's backend state and every other device currently visible on it. Needs a real Tailscale auth key and network access. + +## What this is showing off + +- `TailscaleNode` — bringing up an in-process node against the control plane. +- `LocalAPIClient` — querying the node's own local API for backend/peer status, the same API `tailscale status` itself uses. + +For the raw send/receive primitives (`Listener`/`IncomingConnection`/`OutgoingConnection`), see `swift/TailscaleKitXCTests/TailscaleKitTests.swift`. diff --git a/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift new file mode 100644 index 0000000..f62586a --- /dev/null +++ b/swift/Examples/TailscaleKitCLI/Sources/tsdemo/TSDemo.swift @@ -0,0 +1,80 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import ArgumentParser +import TailscaleKit + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// A minimal proof that TailscaleKit works via plain SwiftPM (including on Linux) +@main +struct TSDemo: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tsdemo", + abstract: "Joins a tailnet and lists its devices, using TailscaleKit via SwiftPM.", + discussion: """ + Needs a Tailscale auth key. Generate a reusable one at + https://login.tailscale.com/admin/settings/keys and pass it via --auth-key or the + TS_AUTHKEY environment variable. + """ + ) + + @Option(help: "Tailscale auth key. Defaults to the TS_AUTHKEY environment variable.") + var authKey: String? + + @Option(help: "Control plane URL.") + var controlURL: String = kDefaultControlURL + + @Flag(help: "Log tsnet's internal activity to stderr instead of discarding it.") + var verbose: Bool = false + + func run() async throws { + guard let authKey = authKey ?? ProcessInfo.processInfo.environment["TS_AUTHKEY"] else { + throw ValidationError( + "Provide --auth-key or set TS_AUTHKEY. Generate one at https://login.tailscale.com/admin/settings/keys" + ) + } + + let hostname = "tsdemo-\(UUID().uuidString.prefix(8))" + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(hostname).path + let config = Configuration( + hostName: hostname, + path: dir, + authKey: authKey, + controlURL: controlURL, + ephemeral: true + ) + let logger: LogSink = verbose ? DefaultLogger() : BlackholeLogger() + let node = try TailscaleNode(config: config, logger: logger) + + print("Bringing up \"\(hostname)\", waiting to associate with the tailnet...") + try await node.up() + + do { + let api = LocalAPIClient(localNode: node, logger: logger) + let status = try await api.backendStatus() + + print("Backend state: \(status.BackendState)") + + let peers = (status.Peer?.values).map(Array.init)?.sorted { $0.HostName < $1.HostName } ?? [] + if peers.isEmpty { + print("No other devices visible on this tailnet yet.") + } else { + print("Devices on this tailnet:") + for peer in peers { + let ip = peer.TailscaleIPs?.first ?? "?" + let mark = peer.Online ? "online " : "offline" + print(" [\(mark)] \(peer.HostName) \(ip) \(peer.DNSName)") + } + } + try await node.close() + } catch { + try? await node.close() + throw error + } + } +} diff --git a/swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift b/swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift index d23ab18..7e08988 100644 --- a/swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift +++ b/swift/Examples/TailscaleKitHello/HelloFromTailscale/HelloViewModel.swift @@ -1,13 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - import SwiftUI -@preconcurrency import Combine import TailscaleKit @Observable -class HelloViewModel: @unchecked Sendable { +class HelloViewModel: @unchecked Sendable { var message: String = "Ready to phone home!" var peerCountMessage = "Waiting for peers...." var stateMessage = "Waiting for state...." @@ -75,7 +73,7 @@ class HelloViewModel: @unchecked Sendable { @MainActor func setMessage(_ message: String) { - self.message = message + self.message = message } func runRequest(_ dialer: Dialer) { diff --git a/swift/Makefile b/swift/Makefile index 4071ff0..31d9c6c 100644 --- a/swift/Makefile +++ b/swift/Makefile @@ -61,9 +61,11 @@ ios-fat: ios-sim ios ## Builds TailscaleKit.xcframework to swift/build/Build/Pro .PHONY: test test: ## Run tests (macOS) - @echo + @echo @echo "::: Running tests for TailscaleKit :::" + rm -f ../tstestcontrol/libtstestcontrol.a cd ../tstestcontrol && make all + rm -f ../libtailscale.a ../libtailscale.h cd .. && make c-archive mkdir -p build xcodebuild build-for-testing -scheme TailscaleKitXCTests \ @@ -78,6 +80,26 @@ test: ## Run tests (macOS) -destination 'platform=macOS,arch=arm64' \ CODE_SIGNING_ALLOWED=NO +.PHONY: spm-setup +spm-setup: ## Build the artifact bundle needed for swift build/swift run. Run once, then swift build/run as many times as you like (cross-platform, incl. Linux) + @echo + @echo "::: Building SwiftPM artifact bundle for TailscaleKit :::" + ./script/build-artifactbundle.sh + +.PHONY: spm-test-setup +spm-test-setup: spm-setup ## Additionally build libtstestcontrol.a needed for swift test. Run once, then swift test as many times as you like + @echo + @echo "::: Building libtstestcontrol.a for TailscaleKit tests :::" + rm -f ../tstestcontrol/libtstestcontrol.a + cd ../tstestcontrol && make all + ./script/fix-tstestcontrol-archive.sh + +.PHONY: test-spm +test-spm: spm-test-setup ## Run tests via Swift Package Manager (cross-platform, incl. Linux) + @echo + @echo "::: Running tests for TailscaleKit via swift test :::" + swift test + .PHONY: clean clean: ## Clean up build artifacts (including the libtailscale dependencies) cd .. && make clean diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 0000000..bd716af --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version:6.3 +import PackageDescription + +let swiftSettings: [SwiftSetting] = [ + /// https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md + /// Require `any` for existential types. + .enableUpcomingFeature("ExistentialAny") +] + +let package = Package( + name: "TailscaleKit", + platforms: [.macOS(.v15), .iOS(.v18)], // same as xcodeproj + products: [ + .library(name: "TailscaleKit", targets: ["TailscaleKit"]) + ], + targets: [ + // No Go toolchain needed to consume it. + .binaryTarget(name: "CTailscale", path: "build/TailscaleKit.artifactbundle"), + .systemLibrary(name: "CTstestControl", path: "Sources/CTstestControl"), + .target( + name: "TailscaleKit", + dependencies: ["CTailscale"], + path: "TailscaleKit", + exclude: ["TailscaleKit.h"], + swiftSettings: swiftSettings + ), + .testTarget( + name: "TailscaleKitTests", + dependencies: ["TailscaleKit", "CTstestControl"], + path: "TailscaleKitXCTests", + swiftSettings: swiftSettings, + // Links against build/libtstestcontrol.a, a copy with its cgo runtime + // symbols renamed to avoid colliding with libtailscale.a's copy. + linkerSettings: [.unsafeFlags(["-L", "build"])] + ), + ] +) diff --git a/swift/README.md b/swift/README.md index f71666c..ba72324 100644 --- a/swift/README.md +++ b/swift/README.md @@ -1,20 +1,20 @@ # TailscaleKit -The TailscaleKit Swift package provides an embedded network interface that can be -used to listen for and dial connections to other [Tailscale](https://tailscale.com) nodes in addition -to an extension to URLSession which allows you to make URL requests to nodes on you Tailnet directly. +The TailscaleKit Swift package provides an embedded network interface that can be used to listen for and dial connections to other [Tailscale](https://tailscale.com) nodes in addition to an extension to URLSession which allows you to make URL requests to nodes on you Tailnet directly. The interfaces are similar in design to NWConnection, but are Swift 6 compliant and -designed to be used in modern async/await style code. +designed to be used in modern async/await style code. ## Build and Install Build Requirements: - - XCode 16.1 or newer + +- XCode 16.1 or newer Building Tailscale.framework: -From /swift +From /swift + ```bash $ make macos $ make ios @@ -24,45 +24,40 @@ $ make ios-fat These recipes build different variants of TailscaleKit.framework into /swift/build/Build/Products. -Separate frameworks will be built for macOS and iOS and the iOS Simulator. All dependencies (libtailscale*.a) -are built automatically. Swift 6 is supported. +Separate frameworks will be built for macOS and iOS and the iOS Simulator. All dependencies (libtailscale\*.a) are built automatically. Swift 6 is supported. -The ios and ios-sim frameworks are purposefully separated. The former is free of any simulator segments -and is suitable for app-store submissions. The latter is suitable for embedding when you -wish to run on a simulator in dev though 'make ios-fat' will produce an xcframework bundle including -both simulator and device frameworks for development. +The ios and ios-sim frameworks are purposefully separated. The former is free of any simulator segments and is suitable for app-store submissions. The latter is suitable for embedding when you wish to run on a simulator in dev though 'make ios-fat' will produce an xcframework bundle including both simulator and device frameworks for development. The frameworks are not signed and must be signed when they are embedded. -Alternatively, you may build from xCode using the Tailscale scheme but the -libraries must be built first (since xCode will complain about paths and -permissions) +Alternatively, you may build from xCode using the Tailscale scheme but the libraries must be built first (since xCode will complain about paths and permissions) + +To build only the static libraries, from / -To build only the static libraries, from / ```bash $ make c-archive -$ make c-archive-ios +$ make c-archive-ios $ make c-archive-ios-sim ``` -If you're writing pure C, or C++, link these and use the generated tailscale.h header. -make c-archive builds for the local machine architecture/platform (arm64 macOS from a mac) +If you're writing pure C, or C++, link these and use the generated tailscale.h header. `make c-archive` builds for the local machine architecture/platform (arm64 macOS from a mac) -Non-apple swift builds are not supported (yet) but should be possible with a little tweaking. +Non-Apple platforms are supported via SwiftPM instead of Xcode: `make spm-setup` builds a `TailscaleKit.artifactbundle` (Linux always, plus native macOS variants when run on a Mac) that `swift build`/`swift test`/`swift run` consume directly, with no Xcode project or Go toolchain required by downstream consumers. See `swift/Examples/TailscaleKitCLI` for a minimal example. ## Tests From /swift + ```bash -$ make test +$ make test # Xcode/XCTest, macOS only +$ make test-spm # swift test, cross-platform (incl. Linux) ``` +On a fresh macOS checkout, make sure `objcopy`/`llvm-objcopy` is reachable (`xcrun -f llvm-objcopy` if you have a swift.org toolchain installed, otherwise `brew install llvm` or `brew install binutils`) before running `make test-spm` - `fix-tstestcontrol-archive.sh` needs it to build `libtstestcontrol.a` for the test target, and macOS doesn't ship one by default. Not needed for `make spm-setup`/`swift build`/`swift run` on their own. ## Usage -Nodes need to be authorized in order to function. Set an auth key via -the config.authKey parameter, or watch the ipn bus (see the example) for -the browseToURL field for interactive web-based auth. +Nodes need to be authorized in order to function. Set an auth key via the config.authKey parameter, or watch the ipn bus (see the example) for the browseToURL field for interactive web-based auth. Here's a working example using an auth key: @@ -78,8 +73,8 @@ func start() -> TailscaleNode { // The logger is configurable. The default will just print. let node = try TailscaleNode(config: config, logger: DefaultLogger()) - - // Bring the node up + + // Bring the node up try await node.up() return node } @@ -90,7 +85,7 @@ func fetchURL(_ url: URL, tailscale: TailscaleNode) async throws -> Data { // You can cache this. It will not change once the node is up. let sessionConfig = try await URLSessionConfiguration.tailscaleSession(tailscale) let session = URLSession(configuration: sessionConfig) - + // Make the request let req = URLRequest(url: url) let (data, _) = try await session.data(for: req) @@ -102,14 +97,11 @@ The "node" created here should show up in the Tailscale admin panel as "TSNet-Te ### LocalAPI -TailscaleKit.framework also includes a functional (though somewhat incomplete) implementation of -LocalAPI which can be used to track the state of the embedded tailscale instance in much greater -detail. +TailscaleKit.framework also includes a functional (though somewhat incomplete) implementation of LocalAPI which can be used to track the state of the embedded tailscale instance in much greater detail. ### Examples -See the TailscaleKitHello example for a relatively complete implementation demonstrating proxied -HTTP and usage of LocalAPI to track the tailnet state. +See the TailscaleKitHello example for a relatively complete implementation demonstrating proxied HTTP and usage of LocalAPI to track the tailnet state. ## Contributing diff --git a/swift/Sources/CTstestControl/module.modulemap b/swift/Sources/CTstestControl/module.modulemap new file mode 100644 index 0000000..f94740f --- /dev/null +++ b/swift/Sources/CTstestControl/module.modulemap @@ -0,0 +1,5 @@ +module CTstestControl [system] { + header "shim.h" + link "tstestcontrol" + export * +} diff --git a/swift/Sources/CTstestControl/shim.h b/swift/Sources/CTstestControl/shim.h new file mode 100644 index 0000000..2e337fd --- /dev/null +++ b/swift/Sources/CTstestControl/shim.h @@ -0,0 +1 @@ +#include "../../../tstestcontrol/tstestcontrol.h" diff --git a/swift/TailscaleKit/IncomingConnection.swift b/swift/TailscaleKit/IncomingConnection.swift index a67766a..b376909 100644 --- a/swift/TailscaleKit/IncomingConnection.swift +++ b/swift/TailscaleKit/IncomingConnection.swift @@ -1,54 +1,77 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import Combine +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif + +#if canImport(Combine) +import Combine +#endif + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif /// IncomingConnection is use to read incoming message from an inbound -/// connection. IncomingConnections are not instantiated directly, +/// connection. IncomingConnections are not instantiated directly, /// they are returned by Listener.accept public actor IncomingConnection { - private let logger: LogSink? + private let logger: (any LogSink)? private var conn: TailscaleConnection = 0 private let reader: SocketReader public let remoteAddress: String? + #if canImport(Combine) @Published var _state: ConnectionState = .idle + #else + private var stateBroadcaster = StateBroadcaster(.idle) + #endif - public func state() -> any AsyncSequence { + public func state() -> some AsyncSequence { + #if canImport(Combine) $_state .removeDuplicates() .eraseToAnyPublisher() .values + #else + stateBroadcaster.subscribe() + #endif } - init(conn: TailscaleConnection, remoteAddress: String?, logger: LogSink? = nil) async { + init(conn: TailscaleConnection, remoteAddress: String?, logger: (any LogSink)? = nil) async { self.logger = logger self.conn = conn - _state = .connected self.remoteAddress = remoteAddress - reader = SocketReader(conn: conn) + self.reader = SocketReader(conn: conn) + setConnectionState(.connected) } deinit { if conn != 0 { - Darwin.close(conn) + _ = System.close(conn) } } public func close() { if conn != 0 { - Darwin.close(conn) + _ = System.close(conn) conn = 0 } - _state = .closed + setConnectionState(.closed) } /// Returns up to size bytes from the connection. Blocks until /// data is available - public func receive(maximumLength: Int = 4096, timeout: Int32) async throws -> Data { - guard _state == .connected else { + public func receive(maximumLength: Int = 4096, timeout: TimeInterval) async throws -> Data { + guard connectionState == .connected else { throw TailscaleError.connectionClosed } @@ -56,13 +79,32 @@ public actor IncomingConnection { } /// Reads a complete message from the connection - public func receiveMessage( timeout: Int32) async throws -> Data { - guard _state == .connected else { + public func receiveMessage(timeout: TimeInterval) async throws -> Data { + guard connectionState == .connected else { throw TailscaleError.connectionClosed } return try await reader.readAll(timeout: timeout) } + + private var connectionState: ConnectionState { + #if canImport(Combine) + _state + #else + stateBroadcaster.value + #endif + } + + private func setConnectionState(_ state: ConnectionState) { + #if canImport(Combine) + _state = state + #else + stateBroadcaster.set(state) + if state == .closed { + stateBroadcaster.finish() + } + #endif + } } /// Serializes read operations from an IncomingConnection @@ -71,23 +113,26 @@ private actor SocketReader { // of a single packet private static let maxBufferSize = 2048 private let conn: TailscaleConnection - private var buffer = [UInt8](repeating:0, count: maxBufferSize) + private var buffer = [UInt8](repeating: 0, count: maxBufferSize) init(conn: TailscaleConnection) { self.conn = conn } - func read(timeout: Int32, len: Int) throws -> Data { + func read(timeout: TimeInterval, len: Int) throws -> Data { + guard timeout >= 0, timeout * 1000 <= Double(Int32.max) else { + throw TailscaleError.invalidTimeout + } + var p: pollfd = .init(fd: conn, events: Int16(POLLIN), revents: 0) - let res = poll(&p, 1, timeout) + let res = poll(&p, 1, Int32(timeout * 1000)) guard res > 0 else { throw TailscaleError.readFailed } let bytesToRead = min(len, Self.maxBufferSize) - var bytesRead = 0 - buffer.withUnsafeMutableBufferPointer { ptr in - bytesRead = Darwin.read(conn, ptr.baseAddress, bytesToRead) + let bytesRead = buffer.withUnsafeMutableBufferPointer { ptr in + System.read(conn, ptr.baseAddress, bytesToRead) } if bytesRead < 0 { @@ -96,7 +141,7 @@ private actor SocketReader { return Data(buffer[0.. Data { + func readAll(timeout: TimeInterval) throws -> Data { var data: Data = .init() while true { let read = try read(timeout: timeout, len: Self.maxBufferSize) @@ -108,4 +153,3 @@ private actor SocketReader { return data } } - diff --git a/swift/TailscaleKit/Listener.swift b/swift/TailscaleKit/Listener.swift index 67d89e3..5e96b66 100644 --- a/swift/TailscaleKit/Listener.swift +++ b/swift/TailscaleKit/Listener.swift @@ -1,26 +1,41 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import Combine import Foundation +#if canImport(CTailscale) +import CTailscale +#endif + +#if canImport(Combine) +import Combine +#endif + /// A Listener is used to await incoming connections from another /// Tailnet node. public actor Listener { - private var tailscale: TailscaleHandle + private var tailscale: TailscaleHandle private var listener: TailscaleListener = 0 private var proto: NetProtocol private var address: String - private let logger: LogSink? + private let logger: (any LogSink)? + #if canImport(Combine) @Published var _state: ListenerState = .idle + #else + private var stateBroadcaster = StateBroadcaster(.idle) + #endif - public func state() -> any AsyncSequence { + public func state() -> some AsyncSequence { + #if canImport(Combine) $_state .removeDuplicates() .eraseToAnyPublisher() .values + #else + stateBroadcaster.subscribe() + #endif } /// Initializes and readies a new listener @@ -32,7 +47,7 @@ public actor Listener { public init(tailscale: TailscaleHandle, proto: NetProtocol, address: String, - logger: LogSink? = nil) async throws { + logger: (any LogSink)? = nil) async throws { self.logger = logger self.tailscale = tailscale self.address = address @@ -41,18 +56,18 @@ public actor Listener { let res = tailscale_listen(tailscale, proto.rawValue, address, &listener) guard res == 0 else { - _state = .failed + setConnectionState(.failed) let msg = tailscale.getErrorMessage() let err = TailscaleError.fromPosixErrCode(res, msg) logger?.log("Listener failed to initialize: \(msg) (\(err.localizedDescription))") throw err } - _state = .listening + setConnectionState(.listening) } deinit { if listener != 0 { - Darwin.close(listener) + _ = System.close(listener) } } @@ -60,10 +75,10 @@ public actor Listener { /// Listeners will be closed automatically on deallocation public func close() { if listener != 0 { - Darwin.close(listener) + _ = System.close(listener) listener = 0 } - _state = .closed + setConnectionState(.closed) } /// Blocks and awaits a new incoming connection @@ -106,7 +121,7 @@ public actor Listener { /// We extract the remove address here for utility so you know /// who's calling, so you can dial back. var remoteAddress: String? - var buffer = [Int8](repeating:0, count: 64) + var buffer = [Int8](repeating: 0, count: 64) buffer.withUnsafeMutableBufferPointer { buf in let err = tailscale_getremoteaddr(listener, connfd, buf.baseAddress, 64) if err == 0 { @@ -125,4 +140,15 @@ public actor Listener { remoteAddress: remoteAddress, logger: logger) } + + private func setConnectionState(_ state: ListenerState) { + #if canImport(Combine) + _state = state + #else + stateBroadcaster.set(state) + if state == .closed { + stateBroadcaster.finish() + } + #endif + } } diff --git a/swift/TailscaleKit/LocalAPI/GoTime.swift b/swift/TailscaleKit/LocalAPI/GoTime.swift index 211277a..c548fac 100644 --- a/swift/TailscaleKit/LocalAPI/GoTime.swift +++ b/swift/TailscaleKit/LocalAPI/GoTime.swift @@ -21,13 +21,13 @@ extension String { let iso8601DateFormatter = { ISO8601DateFormatter() }() - + let iso8601DateFormatterFractionalSeconds: ISO8601DateFormatter = { let dateFormatter = ISO8601DateFormatter() dateFormatter.formatOptions.insert(.withFractionalSeconds) return dateFormatter }() - + // Fractional seconds are optional in RFC3339 as generated by Go/control, // but Foundation date formatters do not parse dates with and without // fractional seconds without specifying the option to look for them. diff --git a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift index 7133505..945b782 100644 --- a/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift +++ b/swift/TailscaleKit/LocalAPI/LocalAPIClient.swift @@ -1,7 +1,15 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif let kLocalAPIPath = "/localapi/v0/" @@ -38,15 +46,14 @@ public actor LocalAPIClient { /// The local node that will be handling our localAPI requests. let node: TailscaleNode - - let logger: LogSink? - public init(localNode: TailscaleNode, logger: LogSink?) { + let logger: (any LogSink)? + + public init(localNode: TailscaleNode, logger: (any LogSink)?) { self.node = localNode self.logger = logger } - // MARK: - IPN Bus /// watchIPNBus subscribes to the IPN notification bus. This is the primary mechanism that should be implemented for observing @@ -62,7 +69,7 @@ public actor LocalAPIClient { /// - consumer: an actor implementing MessageConsumer to which incoming events will be sent /// - Returns: The MessageProcessor handling the incoming event stream. This should be destroyed/stopped when the caller /// wishes to unsubscribe from the event stream. - public func watchIPNBus(mask: Ipn.NotifyWatchOpt, consumer: MessageConsumer) async throws -> MessageProcessor { + public func watchIPNBus(mask: Ipn.NotifyWatchOpt, consumer: any MessageConsumer) async throws -> MessageProcessor { let params = [URLQueryItem(name: "mask", value: String(mask.rawValue))] let (request, sessionConfig) = try await self.basicAuthURLRequest(endpoint: .watchIPNBus, method: .GET, @@ -177,15 +184,15 @@ public actor LocalAPIClient { resultTransformer: jsonDecodeTransformer(IpnLocal.LoginProfile.self)) switch result { - case .success(let result): return result - case .failure(let error): throw error + case .success(let result): return result + case .failure(let error): throw error } } public func addProfile() async throws { let error = await doSimpleAPIRequest( endpoint: .profiles, - path: "", // Important, we need the trailing / + path: "", // Important, we need the trailing / method: .PUT, resultTransformer: errorTransformer) @@ -196,7 +203,7 @@ public actor LocalAPIClient { } public func switchProfile(profileID: String) async throws { - let error = await doSimpleAPIRequest( + let error = await doSimpleAPIRequest( endpoint: .profiles, path: profileID, method: .POST, @@ -227,14 +234,14 @@ public actor LocalAPIClient { /// /// The majority of the information this returns can be observed using watchIPNBus. public func backendStatus() async throws -> IpnState.Status { - let result = await doSimpleAPIRequest( + let result = await doSimpleAPIRequest( endpoint: .status, method: .GET, resultTransformer: jsonDecodeTransformer(IpnState.Status.self)) switch result { - case .success(let result): return result - case .failure(let error): throw error + case .success(let result): return result + case .failure(let error): throw error } } @@ -246,18 +253,26 @@ public actor LocalAPIClient { headers: [String: String]? = nil, params: [URLQueryItem]? = nil) async throws -> (URLRequest, URLSessionConfiguration) { + #if canImport(Network) let (sessionConfig, loopbackConfig) = try await URLSessionConfiguration.tailscaleSession(node) + #else + let sessionConfig = URLSessionConfiguration.default + let loopbackConfig = try await node.loopback() + #endif var endpointPath = endpoint.rawValue if let path { endpointPath = endpointPath + "/" + path } - logger?.log("Requesting \(endpointPath) via \(loopbackConfig.ip!):\(loopbackConfig.port!)") + guard let ip = loopbackConfig.ip, let port = loopbackConfig.port else { + throw LocalAPIError.localAPIURLRequestError + } + logger?.log("Requesting \(endpointPath) via \(ip):\(port)") var urlComponents = URLComponents() - urlComponents.host = loopbackConfig.ip - urlComponents.port = loopbackConfig.port + urlComponents.host = ip + urlComponents.port = port urlComponents.scheme = "http" urlComponents.path = "\(kLocalAPIPath)\(endpointPath)" urlComponents.queryItems = params @@ -281,7 +296,7 @@ public actor LocalAPIClient { private func parseAPIResponse(data: Data?, response: URLResponse?, - error: Error?) -> Result { + error: (any Error)?) -> Result { if let error { return .failure(error) @@ -310,7 +325,7 @@ public actor LocalAPIClient { bodyAsJSON: BodyT, headers: [String: String]? = nil, timeoutInterval: TimeInterval = 60, - resultTransformer: @escaping (_ result: Result) -> ResultT + resultTransformer: @escaping (_ result: Result) -> ResultT ) async -> ResultT { do { let encodedBody = try JSONEncoder().encode(bodyAsJSON) @@ -335,16 +350,16 @@ public actor LocalAPIClient { body: Data? = nil, headers: [String: String]? = nil, timeoutInterval: TimeInterval = 60, - resultTransformer: @escaping (_ result: Result) -> T) async -> T { + resultTransformer: @escaping (_ result: Result) -> T) async -> T { var request: URLRequest var sessionConfig: URLSessionConfiguration do { (request, sessionConfig) = try await self.basicAuthURLRequest(endpoint: endpoint, - path: path, - method: method, - headers: headers, - params: params) + path: path, + method: method, + headers: headers, + params: params) } catch { return resultTransformer(.failure(error)) @@ -369,18 +384,18 @@ public actor LocalAPIClient { } catch { return resultTransformer(.failure(error)) } - } + } // MARK: - Transformers - private func errorTransformer(result: Result) -> Error? { + private func errorTransformer(result: Result) -> (any Error)? { switch result { case .success: return nil case .failure(let error): return error } } - private func jsonDecodeTransformer(_ type: T.Type) -> (_ result: Result) -> Result { + private func jsonDecodeTransformer(_ type: T.Type) -> (_ result: Result) -> Result { return { result in switch result { case .success(let data): diff --git a/swift/TailscaleKit/LocalAPI/MessageProcessor.swift b/swift/TailscaleKit/LocalAPI/MessageProcessor.swift index a851378..f8a7631 100644 --- a/swift/TailscaleKit/LocalAPI/MessageProcessor.swift +++ b/swift/TailscaleKit/LocalAPI/MessageProcessor.swift @@ -3,32 +3,34 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + let kJsonNewline = UInt8(ascii: "\n") /// The polling interval for the message queue -let kProcessorQueuePollInterval: UInt64 = 100_000_000 // Nanos +let kProcessorQueuePollInterval: UInt64 = 100_000_000 // Nanos /// A MessageConsumer consumes incoming messages from the IPNBus and handles any /// potential errors. public protocol MessageConsumer: Actor { - func notify(_ notify: Ipn.Notify) - func error(_ error: Error) + func notify(_ notify: Ipn.Notify) + func error(_ error: any Error) } - /// MessageProcessor pulls queued Decodable messages from a MessageReader, deserializes them /// and forwards the deserialized objects and any errors to the consumer. public class MessageProcessor: @unchecked Sendable { let consumer: any MessageConsumer let reader: MessageReader let workQueue = OperationQueue() - var logger: LogSink? - + var logger: (any LogSink)? // A long running task to poll the queue - var pollTask: Task? + var pollTask: Task? - init(consumer: any MessageConsumer, logger: LogSink?) async { + init(consumer: any MessageConsumer, logger: (any LogSink)?) async { workQueue.maxConcurrentOperationCount = 1 workQueue.name = "io.tailscale.ipn.MessageProcessor.workQueue" @@ -42,7 +44,7 @@ public class MessageProcessor: @unchecked Sendable { reader.stop() } - func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: (@Sendable (Error) -> Void)? = nil) { + func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: (@Sendable (any Error) -> Void)? = nil) { workQueue.addOperation { [weak self] in guard let self = self else { return } logger?.log("Starting MessageProcessor for \(request.url?.absoluteString ?? "nil")") @@ -56,7 +58,7 @@ public class MessageProcessor: @unchecked Sendable { } } - public func cancel() { + public func cancel() { pollTask?.cancel() } @@ -97,7 +99,7 @@ public class MessageProcessor: @unchecked Sendable { } } - func processError(_ error: Error) { + func processError(_ error: any Error) { Task { await consumer.error(error) } diff --git a/swift/TailscaleKit/LocalAPI/MessageReader.swift b/swift/TailscaleKit/LocalAPI/MessageReader.swift index 9dd8ff9..3206acb 100644 --- a/swift/TailscaleKit/LocalAPI/MessageReader.swift +++ b/swift/TailscaleKit/LocalAPI/MessageReader.swift @@ -3,6 +3,10 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + enum MessageQueueError: Error { case queueCongested } @@ -24,7 +28,7 @@ final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable var ipnWatchSession: URLSession? var dataTask: URLSessionDataTask? - var logger: LogSink? + var logger: (any LogSink)? /// FIFO queue for messages awaiting processing var pendingMessages: [Data] = [] @@ -35,9 +39,9 @@ final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable /// restart the processor and queue with an .initialState flag. var congested = false - var errorHandler: (@Sendable (Error) -> Void)? + var errorHandler: (@Sendable (any Error) -> Void)? - init(logger: LogSink? = nil) { + init(logger: (any LogSink)? = nil) { self.logger = logger workQueue.maxConcurrentOperationCount = 1 workQueue.name = "io.tailscale.ipn.MessageReader.workQueue" @@ -48,7 +52,7 @@ final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable workQueue.cancelAllOperations() } - func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: @escaping @Sendable (Error) -> Void ) { + func start(_ request: URLRequest, config: URLSessionConfiguration, errorHandler: @escaping @Sendable (any Error) -> Void ) { workQueue.addOperation { [weak self] in guard let self = self else { return } @@ -91,7 +95,7 @@ final class MessageReader: NSObject, URLSessionDataDelegate, @unchecked Sendable func urlSession(_ session: URLSession, task: URLSessionTask, - didCompleteWithError error: Error?) { + didCompleteWithError error: (any Error)?) { if let error = error { let nsError = error as NSError // Ignore cancellation errors, those are deliberate. diff --git a/swift/TailscaleKit/LocalAPI/Types.swift b/swift/TailscaleKit/LocalAPI/Types.swift index 76132e6..0ac9cae 100644 --- a/swift/TailscaleKit/LocalAPI/Types.swift +++ b/swift/TailscaleKit/LocalAPI/Types.swift @@ -1,7 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif public struct Empty: Sendable { public struct Message: Codable, Sendable {} @@ -248,7 +252,7 @@ public struct IpnState: Sendable { public var Expired: Bool? } - public struct PeerStatusLite: Codable, Sendable, Equatable { + public struct PeerStatusLite: Codable, Sendable, Equatable { public var RxBytes: Int64 public var TxBytes: Int64 public var LastHandshake: Time.Time @@ -258,16 +262,16 @@ public struct IpnState: Sendable { public struct Status: Codable, Sendable { enum CodingKeys: String, CodingKey { case Version, - BackendState, - AuthURL, - TailscaleIPs, - ExitNodeStatus, - Health, - CurrentTailnet, - CertDomains, - Peer, - User, - ClientVersion + BackendState, + AuthURL, + TailscaleIPs, + ExitNodeStatus, + Health, + CurrentTailnet, + CertDomains, + Peer, + User, + ClientVersion case SelfStatus = "Self" } @@ -310,7 +314,7 @@ public struct Netmap: Sendable { public var NodeKey: Key.NodePublic public var Peers: [Tailcfg.Node]? public var Domain: String - public var UserProfiles: [String: Tailcfg.UserProfile] // Keys are tailcfg.UserIDs thet get stringified + public var UserProfiles: [String: Tailcfg.UserProfile] // Keys are tailcfg.UserIDs thet get stringified public var DNS: Tailcfg.DNSConfig? public func currentUserProfile() -> Tailcfg.UserProfile? { @@ -400,7 +404,7 @@ public struct Tailcfg: Sendable { } if let expiryDate = KeyExpiry?.iso8601Date() { - return (expiryDate as NSDate).earlierDate(Date()) == expiryDate && !KeyDoesNotExpire + return expiryDate < Date() } return false @@ -496,4 +500,3 @@ struct GoError: Codable, Sendable, LocalizedError { return Error } } - diff --git a/swift/TailscaleKit/LogSink.swift b/swift/TailscaleKit/LogSink.swift index 25fe60d..626fe66 100644 --- a/swift/TailscaleKit/LogSink.swift +++ b/swift/TailscaleKit/LogSink.swift @@ -3,6 +3,14 @@ import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + /// A generic interface for sinking log messages from the Swift wrapper /// and go public protocol LogSink: Sendable { @@ -18,6 +26,8 @@ public protocol LogSink: Sendable { public struct DefaultLogger: LogSink { public var logFileHandle: Int32? = STDOUT_FILENO + public init() {} + public func log(_ message: String) { NSLog(message) } @@ -27,6 +37,8 @@ public struct DefaultLogger: LogSink { public struct BlackholeLogger: LogSink { public var logFileHandle: Int32? + public init() {} + public func log(_ message: String) { // Go back to the Shadow! } diff --git a/swift/TailscaleKit/OutgoingConnection.swift b/swift/TailscaleKit/OutgoingConnection.swift index 97195f9..7d4b124 100644 --- a/swift/TailscaleKit/OutgoingConnection.swift +++ b/swift/TailscaleKit/OutgoingConnection.swift @@ -2,10 +2,13 @@ // SPDX-License-Identifier: BSD-3-Clause import Foundation -import Combine + +#if canImport(CTailscale) +import CTailscale +#endif /// ConnectionState indicates the state of individual TSConnection instances -public enum ConnectionState { +public enum ConnectionState: Sendable { case idle ///< Reads and writes are not possible. Connections will transition to connected automatically case connected ///< Connected and ready to read/write case closed ///< Closed and ready to be disposed of. Closed connections cannot be reconnected. @@ -13,7 +16,7 @@ public enum ConnectionState { } /// ListenerState indicates the state of individual TSListener instances -public enum ListenerState { +public enum ListenerState: Sendable { case idle ///< Waiting. case listening ///< Listening case closed ///< Closed and ready to be disposed of. @@ -34,7 +37,7 @@ public actor OutgoingConnection { private var address: String private var conn: TailscaleConnection = 0 - private let logger: LogSink + private let logger: any LogSink /// The state of the connection. Listen for transitions to determine /// if the connection may be used for send/receive operations. @@ -51,7 +54,7 @@ public actor OutgoingConnection { public init(tailscale: TailscaleHandle, to address: String, proto: NetProtocol, - logger: LogSink) async throws { + logger: any LogSink) async throws { self.logger = logger self.proto = proto @@ -65,7 +68,7 @@ public actor OutgoingConnection { /// @See tailscale_dial in Tailscale.h /// /// @throws TailscaleError on failure - public func connect() async throws { + public func connect() async throws { let res = tailscale_dial(tailscale, proto.rawValue, address, &conn) guard res == 0 else { @@ -78,7 +81,7 @@ public actor OutgoingConnection { deinit { if conn != 0 { - Darwin.close(conn) + _ = System.close(conn) } } @@ -87,7 +90,7 @@ public actor OutgoingConnection { /// state to .closed public func close() { if conn != 0 { - Darwin.close(conn) + _ = System.close(conn) conn = 0 } state = .closed @@ -101,7 +104,7 @@ public actor OutgoingConnection { throw TailscaleError.connectionClosed } - let bytesWritten = Darwin.write(conn, data.withUnsafeBytes { $0.baseAddress! }, data.count) + let bytesWritten = System.write(conn, data.withUnsafeBytes { $0.baseAddress! }, data.count) if bytesWritten != data.count { throw TailscaleError.shortWrite diff --git a/swift/TailscaleKit/PlatformShims.swift b/swift/TailscaleKit/PlatformShims.swift new file mode 100644 index 0000000..e45ea88 --- /dev/null +++ b/swift/TailscaleKit/PlatformShims.swift @@ -0,0 +1,28 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +// Namespace for the platform libc calls. Actor types in this module declare +// their own close()/read() methods, which shadow the global libc functions +enum System { + #if canImport(Darwin) + static let close = Darwin.close + static let read = Darwin.read + static let write = Darwin.write + #elseif canImport(Glibc) + static let close = Glibc.close + static let read = Glibc.read + static let write = Glibc.write + #elseif canImport(Musl) + static let close = Musl.close + static let read = Musl.read + static let write = Musl.write + #endif +} diff --git a/swift/TailscaleKit/StateBroadcaster.swift b/swift/TailscaleKit/StateBroadcaster.swift new file mode 100644 index 0000000..5611f77 --- /dev/null +++ b/swift/TailscaleKit/StateBroadcaster.swift @@ -0,0 +1,66 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +/// Holds a current value and broadcasts changes to any number of independent +/// AsyncStream subscribers. New subscribers immediately receive the current +/// value (matching CurrentValueSubject semantics). Duplicate sets are +/// suppressed at the source (matching `removeDuplicates()`). +struct StateBroadcaster: ~Copyable { + private(set) var value: Value + private var continuations: [Int: AsyncStream.Continuation] = [:] + private var nextID = 0 + + init(_ initial: Value) { + self.value = initial + } + + deinit { + // Dropping an unfinished continuation finishes its stream anyway; + // this just makes that behavior explicit rather than incidental. + for continuation in continuations.values { + continuation.finish() + } + } + + /// Sets a new value and notifies all live subscribers. + /// No-op if the value is unchanged (removeDuplicates behavior). + mutating func set(_ newValue: Value) { + guard newValue != value else { return } + value = newValue + for (id, continuation) in continuations { + // Lazily prune subscribers whose iteration was cancelled. + if case .terminated = continuation.yield(newValue) { + continuations[id] = nil + } + } + } + + /// Returns a new stream that yields the current value immediately, + /// then every subsequent (distinct) value. + mutating func subscribe() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream( + of: Value.self, + bufferingPolicy: .bufferingNewest(1) + ) + continuation.yield(value) + continuations[nextID] = continuation + nextID += 1 + return stream + } + + /// Ends all subscriber streams. Call this when the owning object + /// reaches a terminal state (e.g. after yielding .closed). + mutating func finish() { + for continuation in continuations.values { + continuation.finish() + } + continuations.removeAll() + } +} + +/// StateBroadcaster must be owned by exactly one actor; it must never cross an +/// isolation boundary +/// TODO: Once the minimum toolchain is Swift 6.4, delete this extension and +/// add `~Sendable` to the type declaration instead (SE-0518; the swap is source-compatible). +@available(*, unavailable) +extension StateBroadcaster: Sendable {} diff --git a/swift/TailscaleKit/TailscaleError.swift b/swift/TailscaleKit/TailscaleError.swift index d979101..7d42bd6 100644 --- a/swift/TailscaleKit/TailscaleError.swift +++ b/swift/TailscaleKit/TailscaleError.swift @@ -1,7 +1,23 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif + +#if canImport(CTailscale) +import CTailscale +#endif + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif public enum TailscaleError: Error { case badInterfaceHandle ///< The tailscale handle is bad. @@ -23,14 +39,13 @@ public enum TailscaleError: Error { if code == -1 { return .internalError(details) } - if let code = POSIXErrorCode(rawValue: code){ - return .posixError( POSIXError(code)) + if let code = POSIXErrorCode(rawValue: code) { + return .posixError(POSIXError(code)) } return unknownPosixError(code, details) } } - extension TailscaleHandle { static let kMaxErrorMessageLength: Int = 256 diff --git a/swift/TailscaleKit/TailscaleNode.swift b/swift/TailscaleKit/TailscaleNode.swift index 34a6f94..8fcd136 100644 --- a/swift/TailscaleKit/TailscaleNode.swift +++ b/swift/TailscaleKit/TailscaleNode.swift @@ -1,6 +1,10 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +#if canImport(CTailscale) +import CTailscale +#endif + public let kDefaultControlURL = "https://controlplane.tailscale.com" @@ -28,7 +32,7 @@ public struct Configuration: Sendable { } /// The layer 3 protocol to use -public enum NetProtocol: String { +public enum NetProtocol: String, Sendable { case tcp = "tcp" case udp = "udp" } @@ -47,7 +51,7 @@ public actor TailscaleNode { /// new IncomingConnections or OutgoingConnections public let tailscale: TailscaleHandle? - private let logger: LogSink? + private let logger: (any LogSink)? /// Instantiate a new TailscaleNode with the given configuration and /// and optional LogSink. If no LogSink is provided, logs will be @@ -57,7 +61,7 @@ public actor TailscaleNode { /// @See tailscale_start in Tailscale.h /// /// @throws TailscaleError on failure - public init(config: Configuration, logger: LogSink?) throws { + public init(config: Configuration, logger: (any LogSink)?) throws { self.logger = logger ?? BlackholeLogger() tailscale = tailscale_new() diff --git a/swift/TailscaleKit/URLSession+Tailscale.swift b/swift/TailscaleKit/URLSession+Tailscale.swift index e0afbc2..8651bb8 100644 --- a/swift/TailscaleKit/URLSession+Tailscale.swift +++ b/swift/TailscaleKit/URLSession+Tailscale.swift @@ -1,14 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -#if os(iOS) -import UIKit -#endif - +#if canImport(Network) +import Foundation import Network public extension URLSessionConfiguration { - /// Adds the a ProxyConfiguration to a URLSessionConfiguration to /// proxy all requests through the given TailscaleNode. /// @@ -39,3 +36,4 @@ public extension URLSessionConfiguration { return (session, config) } } +#endif \ No newline at end of file diff --git a/swift/TailscaleKitXCTests/TailscaleKitTests.swift b/swift/TailscaleKitXCTests/TailscaleKitTests.swift index 8f6333e..5dc1e8c 100644 --- a/swift/TailscaleKitXCTests/TailscaleKitTests.swift +++ b/swift/TailscaleKitXCTests/TailscaleKitTests.swift @@ -1,38 +1,49 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import XCTest +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +#if canImport(CTstestControl) +import CTstestControl +#endif +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing @testable import TailscaleKit -final class TailscaleKitTests: XCTestCase { - var controlURL: String = "" +@Suite +struct TailscaleKitTests: ~Copyable { + let controlURL: String - override func setUp() async throws { - if controlURL == "" { - var buf = [CChar](repeating:0, count: 1024) - let res = buf.withUnsafeMutableBufferPointer { ptr in - return run_control(ptr.baseAddress!, 1024) - } - let len = buf.firstIndex(where: { $0 == 0 }) ?? 0 - let str = buf[0.. Data { let inbound = try await listener.accept() await listener.close() // We can trust the backend here but this is slightly flaky since remoteAddress can be // nil for legitimate reasons. // let inboundIP = await inbound.remoteAddress - // XCTAssertEqual(inboundIP, writerAddr) - - let got = try await inbound.receiveMessage(timeout: 2) - print("got \(got)") - XCTAssert(got == want) + // #expect(inboundIP == writerAddr) - msgReceived.fulfill() + return try await inbound.receiveMessage(timeout: 2) } - - //Make sure somebody is listening - await fulfillment(of: [lisetnerUp], timeout: 5.0) + async let receivedMessage = receiveMessage() let outgoing = try await OutgoingConnection(tailscale: ts2Handle, to: "\(listenerAddr):8081", @@ -113,7 +117,9 @@ final class TailscaleKitTests: XCTestCase { print("sending \(want)") try await outgoing.send(want) - await fulfillment(of: [msgReceived], timeout: 5.0) + let got = try await receivedMessage + print("got \(got)") + #expect(got == want) print("closing conn") await outgoing.close() @@ -121,27 +127,27 @@ final class TailscaleKitTests: XCTestCase { try await ts1.down() try await ts2.down() } catch { - XCTFail("Init Failed: \(error)") + Issue.record("Init Failed: \(error)") } } - /// The hostCount here is load bearing. Each mock host must have a unique - /// path and hostname. - var hostCount = 0 + /// Each mock host must have a unique path and hostname; a UUID guarantees + /// that without needing mutable state on the suite. func mockConfig() -> Configuration { - let temp = getDocumentDirectoryPath().absoluteString + "tailscale\(hostCount)" - hostCount += 1 + let id = UUID().uuidString + let temp = getDocumentDirectoryPath().appending(path: "tailscale-\(id)").path return Configuration( - hostName: "testHost-\(hostCount)", + hostName: "testHost-\(id)", path: temp, authKey: nil, controlURL: controlURL, ephemeral: false) } - + #if canImport(Network) /// Tests that we can fetch a URL via our proxy (though this isn't a URL /// on the tailnet...) + @Test func testProxy() async throws { let config = mockConfig() let logger = BlackholeLogger() @@ -158,11 +164,13 @@ final class TailscaleKitTests: XCTestCase { let (data, _) = try await session.data(for: req) print("Got proxied data \(data.count)") - XCTAssert(data.count > 0) + #expect(data.count > 0) } } + #endif /// Tests that localAPI is functional + @Test func testStatus() async throws { let config = mockConfig() let logger = BlackholeLogger() @@ -174,17 +182,16 @@ final class TailscaleKitTests: XCTestCase { // The local node should be running and online let api = LocalAPIClient(localNode: ts1, logger: logger) let status = try await api.backendStatus() - XCTAssertEqual(status.BackendState, "Running") + #expect(status.BackendState == "Running") let peerStatus = status.SelfStatus! - XCTAssertTrue(peerStatus.Online) + #expect(peerStatus.Online) } catch { - XCTFail(error.localizedDescription) + Issue.record("\(error.localizedDescription)") } } } - func getDocumentDirectoryPath() -> URL { let arrayPaths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) let docDirectoryPath = arrayPaths[0] diff --git a/swift/script/build-artifactbundle.sh b/swift/script/build-artifactbundle.sh new file mode 100755 index 0000000..8474c7c --- /dev/null +++ b/swift/script/build-artifactbundle.sh @@ -0,0 +1,166 @@ +#!/bin/sh +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +# Cross/native-compiles libtailscale.a for each supported triple and packages +# the results into a Swift Package Manager static-library artifact bundle +# (SE-0482). swift/Package.swift consumes that bundle via a binaryTarget +# instead of requiring a prebuilt libtailscale.a to already sit on disk. +# +# Linux triples are cross-compiled from any host given a real cross C +# toolchain per target triple (on Debian/Ubuntu, `apt-get install +# gcc-x86-64-linux-gnu gcc-aarch64-linux-gnu libc6-dev-amd64-cross +# libc6-dev-arm64-cross` covers both - the gcc packages alone are enough for +# whichever arch matches the host, but the other triple needs its libc6-dev-* +# -cross package too or cgo fails with "bits/libc-header-start.h: No such +# file or directory"). Override CC_ to point at a different +# compiler for a given triple. +# +# macOS triples can only be built when this script itself runs on macOS - +# Go's cgo needs a real Mach-O-emitting C toolchain, which isn't available +# cross-platform the way Linux's cross-gcc toolchains are. Apple's own clang +# can target the other CPU architecture via `-arch`, so a single Mac (either +# arch) covers both arm64 and x86_64 without needing two machines. macOS +# variants are only attempted on a Darwin host. +# +# Any triple whose compiler isn't found is skipped with a warning rather than +# failing the whole run - e.g. a plain Mac without the Linux cross-gcc +# toolchains installed will still get its two macOS variants built, which is +# all that's needed for `swift build`/`swift test` on that same Mac. + +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +SWIFT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +REPO_ROOT=$(cd "$SWIFT_DIR/.." && pwd) + +BUNDLE_NAME="TailscaleKit.artifactbundle" +BUNDLE_DIR="$SWIFT_DIR/build/$BUNDLE_NAME" +ARTIFACT_ID="libtailscale" +VERSION="1.0.0" + +rm -rf "$BUNDLE_DIR" +mkdir -p "$BUNDLE_DIR" + +HOST_OS=$(uname -s) + +# triple:GOOS:GOARCH:default-cc +LINUX_TARGETS=" +x86_64-unknown-linux-gnu:linux:amd64:x86_64-linux-gnu-gcc +aarch64-unknown-linux-gnu:linux:arm64:aarch64-linux-gnu-gcc +" + +# Only attempted when HOST_OS = Darwin; see note above. +MACOS_TARGETS=" +arm64-apple-macosx:darwin:arm64:clang -arch arm64 +x86_64-apple-macosx:darwin:amd64:clang -arch x86_64 +" + +variants_json="" + +# build_variant +build_variant() { + triple="$1" + goos="$2" + goarch="$3" + cc="$4" + + cc_bin=$(echo "$cc" | cut -d' ' -f1) + if ! command -v "$cc_bin" >/dev/null 2>&1; then + echo "warning: C compiler '$cc_bin' not found, skipping $triple (set $(cc_override_var "$triple") to point at one)" >&2 + return 0 + fi + + echo "::: Building libtailscale.a for $triple (GOOS=$goos GOARCH=$goarch, CC=$cc) :::" + + variant_dir="$BUNDLE_DIR/$ARTIFACT_ID-$triple" + mkdir -p "$variant_dir/include" + + ( + cd "$REPO_ROOT" + CC="$cc" CGO_ENABLED=1 GOOS="$goos" GOARCH="$goarch" \ + go build -buildvcs=false -buildmode=c-archive -o "$variant_dir/libtailscale.a" . + ) + rm -f "$variant_dir/libtailscale.h" # go build also writes a header; we ship our own below + + cp "$REPO_ROOT/tailscale.h" "$variant_dir/include/tailscale.h" + cat > "$variant_dir/include/module.modulemap" <<'EOF' +module CTailscale [system] { + header "tailscale.h" + export * +} +EOF + + variant_json=$(cat < -> env var name a caller can set to override the +# compiler used for that triple, e.g. arm64-apple-macosx -> CC_arm64_apple_macosx. +cc_override_var() { + printf '%s' "CC_$1" | tr -c 'A-Za-z0-9_' '_' +} + +build_target_table() { + table="$1" + # Some default_cc values contain a space (e.g. "clang -arch arm64"); split + # entries on newline only, or the default whitespace-IFS word-splitting + # would tear a single entry into multiple bogus ones. + old_ifs="$IFS" + IFS=' +' + for entry in $table; do + IFS="$old_ifs" + triple=$(echo "$entry" | cut -d: -f1) + goos=$(echo "$entry" | cut -d: -f2) + goarch=$(echo "$entry" | cut -d: -f3) + default_cc=$(echo "$entry" | cut -d: -f4) + + cc_var=$(cc_override_var "$triple") + cc=$(eval "echo \${$cc_var:-\$default_cc}") + + build_variant "$triple" "$goos" "$goarch" "$cc" + IFS=' +' + done + IFS="$old_ifs" +} + +build_target_table "$LINUX_TARGETS" + +if [ "$HOST_OS" = "Darwin" ]; then + build_target_table "$MACOS_TARGETS" +else + echo "::: Skipping macOS variants (not running on a Darwin host) :::" +fi + +cat > "$BUNDLE_DIR/info.json" < `_crosscall2`, `_cgo_panic` -> +# `__cgo_panic`); ELF (Linux) doesn't. objcopy/llvm-objcopy and nm operate +# on the raw on-disk name, not the source-level one, so the symbol names +# collected below are platform-dependent. + +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +SWIFT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +REPO_ROOT=$(cd "$SWIFT_DIR/.." && pwd) + +SRC_ARCHIVE="$REPO_ROOT/tstestcontrol/libtstestcontrol.a" +OUT_DIR="$SWIFT_DIR/build" +OUT_ARCHIVE="$OUT_DIR/libtstestcontrol.a" + +if [ ! -f "$SRC_ARCHIVE" ]; then + echo "error: $SRC_ARCHIVE not found (run 'make all' in tstestcontrol/ first)" >&2 + exit 1 +fi + +# Any built libtailscale.a variant works as the reference: the glue symbol +# *names* below come from Go's runtime/cgo C source, which doesn't vary by +# GOOS/GOARCH, only their addresses do. +REF_LIBTAILSCALE=$(find "$SWIFT_DIR/build" -name libtailscale.a -print -quit 2>/dev/null || true) +if [ -z "$REF_LIBTAILSCALE" ]; then + echo "error: no built libtailscale.a found under $SWIFT_DIR/build (run 'make spm-setup' first)" >&2 + exit 1 +fi + +# macOS doesn't ship objcopy on PATH by default. Xcode's command line tools are +# built on LLVM but don't expose llvm-objcopy; a swift.org open-source toolchain +# sometimes does under its own usr/bin, which `xcrun -f` can find even when it's +# not on PATH. Otherwise fall back to a Homebrew binutils/llvm install. +OBJCOPY="" +for candidate in objcopy gobjcopy llvm-objcopy; do + if command -v "$candidate" >/dev/null 2>&1; then + OBJCOPY="$candidate" + break + fi +done +if [ -z "$OBJCOPY" ] && command -v xcrun >/dev/null 2>&1; then + OBJCOPY=$(xcrun -f llvm-objcopy 2>/dev/null || true) +fi +if [ -z "$OBJCOPY" ]; then + echo "error: no objcopy found. On macOS, install one with:" >&2 + echo " brew install binutils # provides gobjcopy" >&2 + echo " brew install llvm # provides llvm-objcopy" >&2 + exit 1 +fi +echo "using $OBJCOPY" + +NM="" +for candidate in nm llvm-nm gnm; do + if command -v "$candidate" >/dev/null 2>&1; then + NM="$candidate" + break + fi +done +if [ -z "$NM" ] && command -v xcrun >/dev/null 2>&1; then + NM=$(xcrun -f llvm-nm 2>/dev/null || true) +fi +if [ -z "$NM" ]; then + echo "error: no nm found" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +# Global (externally visible) *defined* symbol names in each archive - `-g +# --defined-only` skips both local symbols (which can't collide across +# archives) and undefined references (which aren't definitions to collide +# over). +"$NM" -g --defined-only "$REF_LIBTAILSCALE" 2>/dev/null | awk 'NF==3 {print $3}' | sort -u > "$WORK_DIR/libtailscale.syms" +"$NM" -g --defined-only "$SRC_ARCHIVE" 2>/dev/null | awk 'NF==3 {print $3}' | sort -u > "$WORK_DIR/libtstestcontrol.syms" +comm -12 "$WORK_DIR/libtailscale.syms" "$WORK_DIR/libtstestcontrol.syms" > "$WORK_DIR/shared.syms" + +SHARED_COUNT=$(wc -l < "$WORK_DIR/shared.syms") +echo "renaming $SHARED_COUNT symbol(s) shared between libtailscale.a and libtstestcontrol.a" + +REDEFINE_ARGS="" +while IFS= read -r sym; do + [ -z "$sym" ] && continue + REDEFINE_ARGS="$REDEFINE_ARGS --redefine-sym ${sym}=${sym}_tstestcontrol" +done < "$WORK_DIR/shared.syms" + +cp "$SRC_ARCHIVE" "$WORK_DIR/libtstestcontrol.a" +cd "$WORK_DIR" +mkdir extract +cd extract +ar x ../libtstestcontrol.a + +for f in *.o; do + # shellcheck disable=SC2086 + "$OBJCOPY" $REDEFINE_ARGS "$f" +done + +rm -f "$OUT_ARCHIVE" +ar rcs "$OUT_ARCHIVE" *.o + +echo "wrote $OUT_ARCHIVE" diff --git a/tstestcontrol/Makefile b/tstestcontrol/Makefile index 7336c4a..e6f595c 100644 --- a/tstestcontrol/Makefile +++ b/tstestcontrol/Makefile @@ -6,7 +6,7 @@ all: libtstestcontrol.a libtstestcontrol.a: - go build -buildmode=c-archive -o $@ + go build -buildvcs=false -buildmode=c-archive -o $@ clean: rm -f libtstestcontrol.a