diff --git a/.gitignore b/.gitignore index 9a2f867..cf48806 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Config/Signing.local.xcconfig !.env.example Secrets.xcconfig GoogleService-Info.plist + +# Generated by scripts/bootstrap_tailscalekit.sh +project.tailscale.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index e5500e3..a7647d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ the text as the git tag message and the GitHub Release notes. ### Added +- Private tailnet inside Codeg: scan the desktop Private QR, sign in once in + Safari, then talk to `*.ts.net` over official TailscaleKit. No Tailscale.app + on the phone. Build with `scripts/bootstrap_tailscalekit.sh`. + ### Changed - Apple signing now uses an ignored local configuration instead of a committed diff --git a/CodegiOS/App/AppModel.swift b/CodegiOS/App/AppModel.swift index 5dd85d7..5d3f44d 100644 --- a/CodegiOS/App/AppModel.swift +++ b/CodegiOS/App/AppModel.swift @@ -23,6 +23,7 @@ final class AppModel { guard oldValue != selectedServerID else { return } resetServerScopedState() UserDefaults.standard.set(selectedServerID?.uuidString, forKey: Self.lastServerKey) + Task { await TailnetSession.shared.prepare(for: selectedServer?.baseURL) } } } @@ -67,6 +68,7 @@ final class AppModel { // anymore — the app must come up already pointed at a server. let persisted = UserDefaults.standard.string(forKey: Self.lastServerKey).flatMap(UUID.init) self.selectedServerID = store.servers.first { $0.id == persisted }?.id ?? store.servers.first?.id + Task { await TailnetSession.shared.prepare(for: selectedServer?.baseURL) } } var selectedServer: ServerProfile? { diff --git a/CodegiOS/Features/Projects/Terminal/TerminalSession.swift b/CodegiOS/Features/Projects/Terminal/TerminalSession.swift index db22eca..06e88ec 100644 --- a/CodegiOS/Features/Projects/Terminal/TerminalSession.swift +++ b/CodegiOS/Features/Projects/Terminal/TerminalSession.swift @@ -150,9 +150,17 @@ final class TerminalSession { private func openSocket(id: String) { // Guard against a stale bootstrap/reconnect resurrecting a superseded id. guard isCurrent(id) else { return } - let socket = TerminalSocket(baseURL: client.baseURL, token: client.token) - runtime.replaceSocket(socket) - socket.start() + Task { [weak self] in + guard let self, self.isCurrent(id) else { return } + let socket = TerminalSocket( + baseURL: self.client.baseURL, + token: self.client.token, + session: await self.client.liveSession() + ) + guard self.isCurrent(id) else { return } + self.runtime.replaceSocket(socket) + socket.start() + } // Per-iteration `weak self`: the loop must NOT hold `self` across `await`. // A running `self.consume()` would retain the session for the socket's whole // lifetime, so `deinit` (which kills the PTY) would never fire while a shell diff --git a/CodegiOS/Features/Servers/ServerEditorModel.swift b/CodegiOS/Features/Servers/ServerEditorModel.swift index 3261fbc..375718b 100644 --- a/CodegiOS/Features/Servers/ServerEditorModel.swift +++ b/CodegiOS/Features/Servers/ServerEditorModel.swift @@ -220,61 +220,21 @@ final class ServerEditorModel { // MARK: - QR scan /// Apply a value decoded from a server QR code, filling the editable fields. - /// - /// codeg's desktop encodes the bare `http://host:port` address, so the common - /// case is a plain URL → the URL field. We're forgiving beyond that: - /// - A JSON payload (`{"url"|"address"|"server", "token"?, "name"?}`) fills the - /// matching fields. - /// - A plain address may carry a `?token=…` query, which is split out into the - /// token field rather than left in the persisted URL. - /// - /// Returns `true` when at least a usable, validated URL was extracted (and - /// clears any stale test result); `false` if the payload isn't a server - /// address, so the sheet can show "not a valid server QR". + /// Desktop Private mode sends JSON `{url, token, mode:"private"}`. @discardableResult func applyScanned(_ raw: String) -> Bool { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - - // 1) JSON payload (defensive — codeg encodes a bare URL, but be forgiving). - if let data = trimmed.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let candidate = (object["url"] ?? object["address"] ?? object["server"]) as? String - guard let candidate, let normalized = Self.normalizedURL(from: candidate) else { return false } - urlString = normalized.absoluteString - if let name = (object["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !name.isEmpty, trimmedName.isEmpty { - self.name = name - } - if let token = (object["token"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty { - self.token = token - } - fieldsChanged() - return true + guard let pairing = CodegPairingParser.parse(raw) else { return false } + urlString = pairing.url.absoluteString + if let name = pairing.name, !name.isEmpty, trimmedName.isEmpty { + self.name = name + } + if let token = pairing.token, !token.isEmpty { + self.token = token + } + if pairing.mode == .privateTailnet { + Task { await TailnetSession.shared.prepare(for: pairing.url) } } - - // 2) Plain address, possibly with a `?token=` query item. - let (address, scannedToken) = Self.splitToken(from: trimmed) - guard let normalized = Self.normalizedURL(from: address) else { return false } - urlString = normalized.absoluteString - if let scannedToken { self.token = scannedToken } fieldsChanged() return true } - - /// Pull an optional `token` query item out of an address, returning the - /// address without it and the token (if present and non-empty). - private static func splitToken(from raw: String) -> (address: String, token: String?) { - guard var components = URLComponents(string: raw), - let items = components.queryItems, !items.isEmpty else { - return (raw, nil) - } - let token = items.first { $0.name.lowercased() == "token" }?.value - let remaining = items.filter { $0.name.lowercased() != "token" } - components.queryItems = remaining.isEmpty ? nil : remaining - let address = components.string ?? raw - let cleanedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) - return (address, (cleanedToken?.isEmpty == false) ? cleanedToken : nil) - } } diff --git a/CodegiOS/Features/Servers/ServerEditorSheet.swift b/CodegiOS/Features/Servers/ServerEditorSheet.swift index 5799fc5..802cbe8 100644 --- a/CodegiOS/Features/Servers/ServerEditorSheet.swift +++ b/CodegiOS/Features/Servers/ServerEditorSheet.swift @@ -190,9 +190,7 @@ struct ServerEditorSheet: View { dismiss() } - /// Fill the address from a scanned QR payload, or surface an error if it - /// isn't a server address. The token still has to be entered by hand (codeg's - /// QR carries only the URL). + /// Fill the address (and token, when the desktop QR includes it). private func handleScanned(_ code: String) { if !model.applyScanned(code) { scanError = "That QR code isn’t a codeg server address. Make sure you’re scanning the server URL QR from codeg." diff --git a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift index 1a23eb3..c920dc2 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift @@ -891,7 +891,11 @@ final class SessionDetailViewModel { streamReconnects = 0 // fresh send → fresh reconnect budget streamGeneration &+= 1 let generation = streamGeneration - let newStream = EventStream(baseURL: client.baseURL, token: client.token) + let newStream = EventStream( + baseURL: client.baseURL, + token: client.token, + session: await client.liveSession() + ) stream = newStream newStream.start() @@ -1070,7 +1074,11 @@ final class SessionDetailViewModel { closeStream() streamGeneration &+= 1 let generation = streamGeneration - let newStream = EventStream(baseURL: client.baseURL, token: client.token) + let newStream = EventStream( + baseURL: client.baseURL, + token: client.token, + session: await client.liveSession() + ) stream = newStream newStream.start() consumerTask = Task { [weak self] in @@ -1463,7 +1471,11 @@ final class SessionDetailViewModel { guard let self, !Task.isCancelled, self.liveTurn === live, self.isTurnActive, generation == self.streamGeneration else { return } - let newStream = EventStream(baseURL: self.client.baseURL, token: self.client.token) + let newStream = EventStream( + baseURL: self.client.baseURL, + token: self.client.token, + session: await self.client.liveSession() + ) self.stream = newStream newStream.start() self.consumerTask = Task { [weak self] in diff --git a/CodegiOS/Networking/CodegClient.swift b/CodegiOS/Networking/CodegClient.swift index c00880e..604f4e2 100644 --- a/CodegiOS/Networking/CodegClient.swift +++ b/CodegiOS/Networking/CodegClient.swift @@ -61,6 +61,11 @@ struct CodegClient: Sendable { // MARK: - Endpoints + /// WebSocket session that uses the embedded tailnet when the host is `*.ts.net`. + func liveSession() async -> URLSession { + await TailnetSession.shared.session(for: baseURL, fallback: session) + } + /// Validate connectivity + auth for a server profile. Uses the snappier read /// session (15s) so a dead LAN host surfaces as offline in ~15s rather than /// the 30s default — this endpoint normally answers in well under a second, @@ -368,7 +373,10 @@ struct CodegClient: Sendable { /// body — used for settings objects that must preserve snake_case keys the /// shared encoder/decoder would otherwise mangle (e.g. delegation settings). func send(_ path: String, rawBody: Data, session: URLSession? = nil) async throws -> Data { - let session = session ?? self.session + let session = await TailnetSession.shared.session( + for: baseURL, + fallback: session ?? self.session + ) let url = baseURL.appendingPathComponent("api").appendingPathComponent(path) var request = URLRequest(url: url) request.httpMethod = "POST" diff --git a/CodegiOS/Networking/CodegPairing.swift b/CodegiOS/Networking/CodegPairing.swift new file mode 100644 index 0000000..2f057c2 --- /dev/null +++ b/CodegiOS/Networking/CodegPairing.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Desktop Web Service QR payload. Keep in lock-step with +/// `src/lib/codeg-pairing.ts` in the Codeg desktop repo. +struct CodegPairing: Equatable, Sendable { + enum Mode: String, Sendable { + case local + case privateTailnet = "private" + case publicInternet = "public" + } + + var url: URL + var token: String? + var name: String? + var mode: Mode +} + +enum CodegPairingParser { + static func parse(_ raw: String) -> CodegPairing? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let candidate = (object["url"] ?? object["address"] ?? object["server"]) as? String + guard let candidate, let url = Self.url(from: candidate) else { return nil } + let token = (object["token"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = (object["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let mode = parseMode(object["mode"] as? String, url: url) + return CodegPairing( + url: url, + token: (token?.isEmpty == false) ? token : nil, + name: (name?.isEmpty == false) ? name : nil, + mode: mode + ) + } + + let (address, token) = splitToken(from: trimmed) + guard let url = Self.url(from: address) else { return nil } + return CodegPairing(url: url, token: token, name: nil, mode: parseMode(nil, url: url)) + } + + private static func parseMode(_ raw: String?, url: URL) -> CodegPairing.Mode { + switch raw?.lowercased() { + case "private": return .privateTailnet + case "public": return .publicInternet + case "local": return .local + default: + return PrivateHost.needsEmbeddedTailnet(url) ? .privateTailnet : .local + } + } + + private static func url(from raw: String) -> URL? { + ServerEditorModel.normalizedURL(from: raw) + } + + private static func splitToken(from raw: String) -> (String, String?) { + guard var components = URLComponents(string: raw), + let items = components.queryItems, !items.isEmpty else { + return (raw, nil) + } + let token = items.first { $0.name.lowercased() == "token" }?.value + let remaining = items.filter { $0.name.lowercased() != "token" } + components.queryItems = remaining.isEmpty ? nil : remaining + let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) + return (components.string ?? raw, (cleaned?.isEmpty == false) ? cleaned : nil) + } +} diff --git a/CodegiOS/Networking/PrivateHost.swift b/CodegiOS/Networking/PrivateHost.swift new file mode 100644 index 0000000..31bd8c5 --- /dev/null +++ b/CodegiOS/Networking/PrivateHost.swift @@ -0,0 +1,9 @@ +import Foundation + +enum PrivateHost { + /// Official Tailscale Serve / MagicDNS hosts. These are tailnet-only. + static func needsEmbeddedTailnet(_ url: URL) -> Bool { + let host = (url.host ?? "").lowercased() + return host.hasSuffix(".ts.net") + } +} diff --git a/CodegiOS/Networking/TailnetSession.swift b/CodegiOS/Networking/TailnetSession.swift new file mode 100644 index 0000000..a6e8f00 --- /dev/null +++ b/CodegiOS/Networking/TailnetSession.swift @@ -0,0 +1,187 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#endif +#if CODEG_TAILSCALEKIT +import TailscaleKit +#endif + +/// Userspace Tailscale inside Codeg. Same privacy as Tailscale on both devices, +/// without installing Tailscale.app. Requires TailscaleKit (see +/// `scripts/bootstrap_tailscalekit.sh`). +@MainActor +final class TailnetSession { + static let shared = TailnetSession() + + private(set) var isStarting = false + private(set) var lastError: String? + private var openedLoginURLs = Set() + private var startTask: Task? + #if CODEG_TAILSCALEKIT + private var node: TailscaleNode? + #endif + + func session(for url: URL, fallback: URLSession) async -> URLSession { + guard PrivateHost.needsEmbeddedTailnet(url) else { return fallback } + do { + return try await proxied(fallback) + } catch { + lastError = error.localizedDescription + return fallback + } + } + + func prepare(for url: URL?) async { + guard let url, PrivateHost.needsEmbeddedTailnet(url) else { return } + _ = try? await ensureNode() + } + + private func proxied(_ fallback: URLSession) async throws -> URLSession { + #if CODEG_TAILSCALEKIT + let node = try await ensureNode() + guard let cfg = fallback.configuration.copy() as? URLSessionConfiguration else { + return fallback + } + _ = try await cfg.proxyVia(node) + return URLSession(configuration: cfg) + #else + throw TailnetError.kitMissing + #endif + } + + #if CODEG_TAILSCALEKIT + private func ensureNode() async throws -> TailscaleNode { + if let node { return node } + if let startTask { + try await startTask.value + if let node { return node } + } + isStarting = true + lastError = nil + let task = Task { try await self.startNode() } + startTask = task + defer { + startTask = nil + isStarting = false + } + try await task.value + guard let node else { throw TailnetError.loginTimeout } + return node + } + + private func startNode() async throws { + let dir = try Self.stateDirectory() + let config = Configuration( + hostName: "codeg-ios", + path: dir.path, + authKey: nil, + controlURL: kDefaultControlURL, + ephemeral: false + ) + let started = try TailscaleNode(config: config, logger: RedactingTailnetLogger()) + try await started.up() + node = started + + let api = LocalAPIClient(localNode: started, logger: RedactingTailnetLogger()) + if let status = try? await api.backendStatus(), status.BackendState == "Running" { + return + } + let waiter = TailnetLoginWaiter(onLogin: { [weak self] login in + await self?.openLoginOnce(login) + }) + let mask: Ipn.NotifyWatchOpt = [.initialState, .prefs] + let processor = try await api.watchIPNBus(mask: mask, consumer: waiter) + defer { processor.cancel() } + try await api.startLoginInteractive() + + let deadline = Date().addingTimeInterval(180) + while Date() < deadline { + if await waiter.isRunning { return } + try await Task.sleep(nanoseconds: 400_000_000) + } + throw TailnetError.loginTimeout + } + + private static func stateDirectory() throws -> URL { + let root = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let dir = root.appendingPathComponent("codeg-tailnet", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + #else + private func ensureNode() async throws { + throw TailnetError.kitMissing + } + #endif + + private func openLoginOnce(_ raw: String) async { + guard !openedLoginURLs.contains(raw), let url = URL(string: raw) else { return } + openedLoginURLs.insert(raw) + #if canImport(UIKit) + await UIApplication.shared.open(url) + #endif + } +} + +enum TailnetError: LocalizedError { + case kitMissing + case loginTimeout + + var errorDescription: String? { + switch self { + case .kitMissing: + return "Private tailnet support is not in this build. On a Mac run scripts/bootstrap_tailscalekit.sh, then xcodegen generate." + case .loginTimeout: + return "Timed out waiting for the private tailnet login to finish." + } + } +} + +#if CODEG_TAILSCALEKIT +private actor TailnetLoginWaiter: MessageConsumer { + private let onLogin: @MainActor (String) async -> Void + private(set) var isRunning = false + + init(onLogin: @escaping @MainActor (String) async -> Void) { + self.onLogin = onLogin + } + + func notify(_ notify: Ipn.Notify) { + if notify.State == .Running { + isRunning = true + } + if let url = notify.BrowseToURL, !url.isEmpty { + Task { @MainActor in + await onLogin(url) + } + } + } + + func error(_ error: Error) {} +} + +private struct RedactingTailnetLogger: LogSink { + var logFileHandle: Int32? { nil } + + func log(_ message: String) { + #if DEBUG + print("[codeg-tailnet]", redact(message)) + #endif + } + + private func redact(_ message: String) -> String { + var out = message + for needle in ["tskey-", "token=", "Authorization:", "Bearer "] { + if let range = out.range(of: needle, options: .caseInsensitive) { + out.replaceSubrange(range.lowerBound..") + } + } + return out + } +} +#endif diff --git a/README.md b/README.md index 6b7a7cf..f93ede7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,17 @@ codeg backend; the app only calls its HTTP + WebSocket API. Simulator builds do not require an Apple Developer account: +Private tailnet (same privacy as Tailscale on both devices, no Tailscale.app +on the phone) needs official TailscaleKit. On a Mac: + +```bash +./scripts/bootstrap_tailscalekit.sh # latest libtailscale, records the SHA +xcodegen generate +``` + +Without that step the app still builds. `*.ts.net` servers then need the +Tailscale app on the phone, or they fail closed with a rebuild hint. + ```bash xcodegen generate # regenerate CodegiOS.xcodeproj from project.yml open CodegiOS.xcodeproj @@ -107,10 +118,11 @@ Store Connect. To automate that text too, graduate `--archive` to Fastlane ## Connecting to a server -1. Start a codeg server (`CODEG_PORT` default `3080`). It prints a `CODEG_TOKEN` - to stderr on startup. -2. In the app, tap **+**, enter a name, the server URL (e.g. `http://192.168.1.10:3080`), - and the token. Use **Test Connection** to validate, then **Save**. +1. On the PC, start Web Service and turn on **Private** (Tailscale Serve). +2. In the app, tap **+** and scan the desktop QR. That fills the `*.ts.net` + URL and token. The first time, Codeg opens Tailscale login in Safari, + then stays on your tailnet (no Tailscale.app). +3. Or enter a LAN URL by hand and paste the token. Use **Test Connection**, then **Save**. ## Architecture diff --git a/Vendor/.gitignore b/Vendor/.gitignore new file mode 100644 index 0000000..aeab110 --- /dev/null +++ b/Vendor/.gitignore @@ -0,0 +1,3 @@ +libtailscale/ +TailscaleKit/*.xcframework +TailscaleKit/VERSION diff --git a/Vendor/TailscaleKit/.gitkeep b/Vendor/TailscaleKit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/project.yml b/project.yml index 7a63c42..e24ca19 100644 --- a/project.yml +++ b/project.yml @@ -11,6 +11,12 @@ configFiles: Debug: Config/Signing.xcconfig Release: Config/Signing.xcconfig +# Written by scripts/bootstrap_tailscalekit.sh after TailscaleKit is built. +# Optional so a clone still generates without the private-tailnet framework. +include: + - path: project.tailscale.yml + optional: true + packages: # Native VT100/xterm terminal emulator (the equivalent of the web client's # xterm.js) — backs the folder detail's Terminal tab. First SPM dependency in diff --git a/scripts/bootstrap_tailscalekit.sh b/scripts/bootstrap_tailscalekit.sh new file mode 100644 index 0000000..35ccf6c --- /dev/null +++ b/scripts/bootstrap_tailscalekit.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Build official TailscaleKit (latest libtailscale) and wire it into Codeg iOS. +# macOS + Xcode + Go required. Run from the repo root, then `xcodegen generate`. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VENDOR="$ROOT/Vendor" +SRC="$VENDOR/libtailscale" +OUT="$VENDOR/TailscaleKit" +REPO="https://github.com/tailscale/libtailscale.git" + +if ! command -v go >/dev/null 2>&1; then + echo "Go is required to build TailscaleKit. Install the current Go release." >&2 + exit 1 +fi +if ! command -v xcodebuild >/dev/null 2>&1; then + echo "Xcode is required to build TailscaleKit." >&2 + exit 1 +fi + +mkdir -p "$VENDOR" +if [[ -d "$SRC/.git" ]]; then + git -C "$SRC" fetch --depth 1 origin main + git -C "$SRC" checkout --force FETCH_HEAD +else + git clone --depth 1 --branch main "$REPO" "$SRC" +fi + +SHA="$(git -C "$SRC" rev-parse HEAD)" +echo "Building TailscaleKit from libtailscale $SHA" + +make -C "$SRC/swift" ios-fat + +mkdir -p "$OUT" +# The fat xcframework lands under swift/build; copy whatever make produced. +FOUND="$(find "$SRC/swift" -name 'TailscaleKit.xcframework' -type d | head -n 1)" +if [[ -z "$FOUND" ]]; then + echo "make ios-fat did not produce TailscaleKit.xcframework" >&2 + exit 1 +fi +rm -rf "$OUT/TailscaleKit.xcframework" +cp -R "$FOUND" "$OUT/TailscaleKit.xcframework" +printf '%s\n' "$SHA" > "$OUT/VERSION" + +cat > "$ROOT/project.tailscale.yml" <