// AUTO-GENERATED from gstack/ios-qa/templates/StateServer.swift.template // Regenerate with: /ios-sync // // StateServer — HTTP server embedded in the iOS app under test. Loopback-only. // All tailnet ingress is the responsibility of the Mac-side daemon. // // Threat model: this surface is reachable from the local Mac via the CoreDevice // IPv6 tunnel. It MUST refuse any caller without a current bearer token. The // boot token is rotated within ~5 seconds of daemon spawn so anything scraping // os_log past that window sees a dead credential. import Foundation import Network import os.log #if DEBUG public typealias JSONDict = [String: Any] @MainActor public final class StateServer { // MARK: Public surface public static let shared = StateServer() // MARK: Configuration private let logger = Logger(subsystem: "gstack.ios-qa", category: "StateServer") private let port: UInt16 private let bootTokenPath: String // Two listeners for dual-stack loopback. The fork's single-listener IPv6-only // binding was caught in eng + outside-voice review as incomplete. private var ipv6Listener: NWListener? private var ipv4Listener: NWListener? // Auth state. The boot token is what we wrote to os_log on first launch. // It exists ONLY long enough for the daemon to call /auth/rotate. private var bootToken: String private var rotatedToken: String? // set after first /auth/rotate private var bootTokenValid: Bool = true // MARK: Session lock (per-device, sliding window on mutations only) private struct Session { let id: String var lastMutationAt: Date } private var activeSession: Session? private let sessionTtlSeconds: TimeInterval = 300 // 5 min orphan timeout // MARK: Accessor registry (populated by codegen) public typealias ReadHandler = () -> Any? public typealias WriteHandler = (Any) -> Bool public typealias TypeName = String private var readHandlers: [String: ReadHandler] = [:] private var writeHandlers: [String: WriteHandler] = [:] private var typeNames: [String: TypeName] = [:] // Atomic-restore hook. Codegen wires this to the canonical AppState struct. // Restore replaces the entire struct in one assignment so SwiftUI's Combine // pipeline observes exactly one change notification — true observable // atomicity. @MainActor alone doesn't guarantee that. public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult public enum RestoreResult { case ok case missingKey(String) case typeMismatch(String) case schemaMismatch(expected: String, got: String) } private var atomicRestore: AtomicRestoreFn? // Snapshot schema hash — written by codegen, stable across builds with // identical accessor signatures. private var accessorHash: String = "uninitialized" private var appBuildId: String = "uninitialized" // Agent identity for the DebugOverlay attribution chip. Display-only, // never used for auth. public private(set) var lastAgentIdentity: String = "Claude Code (local)" // MARK: Lifecycle private init(port: UInt16 = 9999) { self.port = port self.bootToken = UUID().uuidString self.bootTokenPath = NSTemporaryDirectory() + "gstack-ios-qa.token" } public func start() { // 1. Persist boot token to a 0600 file (best-effort fallback for the // daemon if os_log scrape misses). try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath) // 2. Log the boot token EXACTLY ONCE so the daemon can scrape it. // The daemon will rotate immediately; this log line is dead within // seconds. logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") // 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6; // local tooling may use IPv4. Never bind 0.0.0.0 or ::. startListener(family: .ipv6) startListener(family: .ipv4) } public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) { self.appBuildId = buildId self.accessorHash = accessorHash self.atomicRestore = atomicRestore } public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) { readHandlers[key] = read writeHandlers[key] = write typeNames[key] = type } // MARK: Listener setup private enum AddressFamily { case ipv4 case ipv6 var host: NWEndpoint.Host { switch self { case .ipv4: return NWEndpoint.Host("127.0.0.1") case .ipv6: return NWEndpoint.Host("::1") } } } private func startListener(family: AddressFamily) { do { // Binding strategy: accept connections from the device's loopback // AND from the CoreDevice tunnel (the USB-mounted tunnel the Mac // daemon uses to reach this app — appears as a non-loopback // utun-style interface on the device with the peer's source // address in the fd*/fc* ULA range). We can't use // params.acceptLocalOnly — Network.framework's definition of // "local" is strictly loopback and silently drops CoreDevice // tunnel peers. Instead we accept on the wildcard interface and // do a per-connection peer-address check below: loopback OR // RFC 4193 ULA (fc00::/7) → accept, everything else → cancel. let params = NWParameters.tcp params.allowLocalEndpointReuse = true let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) listener.stateUpdateHandler = { [weak self] state in Task { @MainActor in if case .ready = state { self?.logger.notice("StateServer listening on \(String(describing: family))") } else if case .failed(let err) = state { self?.logger.error("StateServer listener failed: \(err.localizedDescription, privacy: .public)") } } } listener.newConnectionHandler = { [weak self] connection in Task { @MainActor in // Defense-in-depth: even with .loopback interface gate, double-check // the peer is loopback. Reject otherwise. if let self, self.isLoopbackPeer(connection) { self.handle(connection) } else { connection.cancel() } } } listener.start(queue: .global(qos: .userInitiated)) switch family { case .ipv6: ipv6Listener = listener case .ipv4: ipv4Listener = listener } } catch { logger.error("Listener bind failed (\(String(describing: family))): \(error.localizedDescription, privacy: .public)") } } private func isLoopbackPeer(_ connection: NWConnection) -> Bool { switch connection.endpoint { case .hostPort(let host, _): switch host { case .ipv4(let addr): return addr == .loopback case .ipv6(let addr): // Loopback (::1) — local same-device traffic if addr.isLoopback { return true } // CoreDevice ULA range (fd00::/8 unique-local addresses) — // the USB tunnel that the Mac daemon uses to reach this app. // Apple's CoreDevice tunnel uses fd-prefixed ULAs like // fd72:8347:2ead::1 (Mac-facing) and fd72:8347:2ead::2 // (device-facing). We accept the entire ULA range since // the prefix is regenerated per session. let bytes = addr.rawValue if bytes.count >= 1 && (bytes[0] & 0xFE) == 0xFC { // RFC 4193 ULA range (fc00::/7) — fc* or fd* prefix. return true } return false case .name(let name, _): return name == "localhost" @unknown default: return false } default: return false } } // MARK: Request handling private func handle(_ connection: NWConnection) { connection.start(queue: .global(qos: .userInitiated)) receive(connection: connection, buffer: Data()) } private static let maxBodyBytes = 1_048_576 // 1MB hard cap private func receive(connection: NWConnection, buffer: Data) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in guard let self else { return } Task { @MainActor in var current = buffer if let data = data { current.append(data) } if current.count > Self.maxBodyBytes { self.send(connection: connection, status: 413, body: ["error": "body_too_large"]) return } if let req = self.tryParseRequest(current) { self.route(connection: connection, request: req) } else if isComplete || error != nil { self.send(connection: connection, status: 400, body: ["error": "bad_request"]) } else { self.receive(connection: connection, buffer: current) } } } } struct ParsedRequest { let method: String let path: String let headers: [String: String] let body: Data } private func tryParseRequest(_ data: Data) -> ParsedRequest? { guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else { return nil } let headerData = data.subdata(in: 0..= 2 else { return nil } var headers: [String: String] = [:] for line in lines.dropFirst() { guard let colon = line.firstIndex(of: ":") else { continue } let key = String(line[.. Bool { guard let auth = request.headers["authorization"], auth.hasPrefix("Bearer ") else { return false } let token = String(auth.dropFirst("Bearer ".count)) return token == rotatedToken } private func handleAuthRotate(connection: NWConnection, request: ParsedRequest) { // Validate boot token (still alive AND used only once). guard bootTokenValid, let auth = request.headers["authorization"], auth.hasPrefix("Bearer "), String(auth.dropFirst("Bearer ".count)) == bootToken else { send(connection: connection, status: 401, body: ["error": "boot_token_invalid"]) return } guard let dict = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict, let newToken = dict["new_token"] as? String, newToken.count >= 16 else { send(connection: connection, status: 400, body: ["error": "invalid_rotate_payload"]) return } rotatedToken = newToken bootTokenValid = false // Best-effort scrub of on-disk boot token file. try? FileManager.default.removeItem(atPath: bootTokenPath) logger.notice("Boot token rotated; original now invalid") send(connection: connection, status: 200, body: ["ok": true]) } // MARK: Session lock private static let mutatingPaths: Set = ["/tap", "/swipe", "/type", "/state/restore"] private func mutatingPathRequiresSession(_ path: String, method: String) -> Bool { if method != "POST" { return false } if path.hasPrefix("/state/") && path != "/state/restore" { return true } // /state/ writes return Self.mutatingPaths.contains(path) } private func requireSession(in request: ParsedRequest, connection: NWConnection) -> Bool { guard let id = request.headers["x-session-id"] else { send(connection: connection, status: 409, body: ["error": "session_required"]) return false } guard let current = activeSession, current.id == id else { send(connection: connection, status: 409, body: ["error": "session_invalid_or_expired"]) return false } // Mutation slides the lock; reads do not. activeSession?.lastMutationAt = Date() return true } private func handleSessionAcquire(connection: NWConnection) { // Reap orphaned session. if let s = activeSession, Date().timeIntervalSince(s.lastMutationAt) > sessionTtlSeconds { activeSession = nil } if activeSession != nil { send(connection: connection, status: 423, body: ["error": "device_locked"]) return } let id = UUID().uuidString activeSession = Session(id: id, lastMutationAt: Date()) send(connection: connection, status: 200, body: [ "session_id": id, "ttl_seconds": Int(sessionTtlSeconds), ]) } private func handleSessionRelease(connection: NWConnection) { activeSession = nil send(connection: connection, status: 200, body: ["ok": true]) } private func handleSessionHeartbeat(connection: NWConnection, request: ParsedRequest) { guard let id = request.headers["x-session-id"], activeSession?.id == id else { send(connection: connection, status: 409, body: ["error": "session_invalid_or_expired"]) return } activeSession?.lastMutationAt = Date() send(connection: connection, status: 200, body: ["ok": true, "ttl_seconds": Int(sessionTtlSeconds)]) } // MARK: State handlers private func handleStateGet(connection: NWConnection, key: String) { guard let handler = readHandlers[key] else { send(connection: connection, status: 404, body: ["error": "unknown_key", "key": key]) return } let value = handler() ?? NSNull() send(connection: connection, status: 200, body: ["key": key, "value": value]) } private func handleStateWrite(connection: NWConnection, request: ParsedRequest, key: String) { guard requireSession(in: request, connection: connection) else { return } guard let handler = writeHandlers[key] else { send(connection: connection, status: 404, body: ["error": "unknown_key", "key": key]) return } guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict, let value = payload["value"] else { send(connection: connection, status: 400, body: ["error": "missing_value"]) return } let ok = handler(value) if ok { send(connection: connection, status: 200, body: ["ok": true]) } else { send(connection: connection, status: 400, body: ["error": "type_mismatch", "expected": typeNames[key] ?? "?"]) } } private func handleSnapshotGet(connection: NWConnection) { var keys: JSONDict = [:] for (k, read) in readHandlers { keys[k] = read() ?? NSNull() } let envelope: JSONDict = [ "_schema_version": 1, "_app_build_id": appBuildId, "_accessor_hash": accessorHash, "keys": keys, ] send(connection: connection, status: 200, body: envelope) } private func handleSnapshotRestore(connection: NWConnection, request: ParsedRequest) { guard requireSession(in: request, connection: connection) else { return } guard let envelope = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else { send(connection: connection, status: 400, body: ["error": "invalid_json"]) return } // Schema gate. if let hash = envelope["_accessor_hash"] as? String, hash != accessorHash { send(connection: connection, status: 409, body: [ "error": "schema_mismatch", "expected_hash": accessorHash, "got_hash": hash, ]) return } guard let keys = envelope["keys"] as? JSONDict else { send(connection: connection, status: 400, body: ["error": "missing_keys"]) return } guard let restore = atomicRestore else { send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"]) return } // Validate-then-apply via the codegen-supplied closure. The closure does // a single struct-assignment so SwiftUI sees one change notification. switch restore(keys) { case .ok: send(connection: connection, status: 200, body: ["ok": true]) case .missingKey(let k): send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"]) case .typeMismatch(let k): send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"]) case .schemaMismatch(let expected, let got): send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got]) } } // MARK: Stubs (real impls live in DebugBridgeManager + UIKit) private func handleElements(connection: NWConnection) { let tree = ElementsBridge.snapshot() send(connection: connection, status: 200, body: ["elements": tree]) } private func handleScreenshot(connection: NWConnection) { if let png = ScreenshotBridge.capturePNG() { send(connection: connection, status: 200, body: ["png_base64": png.base64EncodedString()]) } else { send(connection: connection, status: 500, body: ["error": "screenshot_unavailable"]) } } private func handleMutation(connection: NWConnection, request: ParsedRequest, op: String) { guard requireSession(in: request, connection: connection) else { return } guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else { send(connection: connection, status: 400, body: ["error": "invalid_json"]) return } let ok = MutationBridge.dispatch(op: op, payload: payload) send(connection: connection, status: ok ? 200 : 400, body: ["op": op, "ok": ok]) } // MARK: Response private func send(connection: NWConnection, status: Int, body: JSONDict) { let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8) let statusText: String switch status { case 200: statusText = "OK" case 400: statusText = "Bad Request" case 401: statusText = "Unauthorized" case 404: statusText = "Not Found" case 409: statusText = "Conflict" case 413: statusText = "Payload Too Large" case 423: statusText = "Locked" case 429: statusText = "Too Many Requests" case 500: statusText = "Internal Server Error" case 503: statusText = "Service Unavailable" default: statusText = "Status" } let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n" var packet = Data(header.utf8) packet.append(json) connection.send(content: packet, completion: .contentProcessed { _ in connection.cancel() }) } } // MARK: - Bridges (implementation provided by DebugBridgeManager) @MainActor public enum ElementsBridge { public static var resolver: () -> [JSONDict] = { [] } static func snapshot() -> [JSONDict] { resolver() } } @MainActor public enum ScreenshotBridge { public static var resolver: () -> Data? = { nil } static func capturePNG() -> Data? { resolver() } } @MainActor public enum MutationBridge { public static var resolver: (String, JSONDict) -> Bool = { _, _ in false } static func dispatch(op: String, payload: JSONDict) -> Bool { resolver(op, payload) } } #endif // DEBUG