import APIClient import ClientTLS import Foundation import HostRegistry import Observation import WireProtocol /// T-iOS-12 · Pairing state machine (plan §7 / §5.4). /// /// Flow: scan / manual URL → **confirm gate** → two-step probe → /// `Host{id,name}` into the `HostStore` + navigate signal. /// /// Security invariants (plan §5 / task RED list): /// - Scan payloads are UNTRUSTED external input: parsed exclusively through /// `HostEndpoint` (single-point derivation — no hand-assembly), non-http(s) /// rejected with copy, and **zero network happens before the user confirms** /// (probe ① already GETs the target; probe ② spawns a PTY on it). /// - The §5.4 warning tiers render ON the confirm page; the public-host tier /// is BLOCKING — `confirmConnect` refuses to probe until the user has set /// `hasAcknowledgedPublicRisk` explicitly. /// - Every `PairingError` maps to inline copy + a recovery action /// (`localNetworkDenied` → Settings deep-link; `originRejected` surfaces the /// probe's hint VERBATIM; `atsBlocked` uses the §3.4 wording). /// /// Documented decisions: /// - **Manual entry reuses the same confirm state as scanning** (task left it /// free): one code path, and the §5.4 warning tiers apply uniformly to /// typed URLs too. Convenience: input without `://` gets an `http://` /// prefix before the `HostEndpoint` parse (scan payloads get NO such help). /// - **Host classification is (re)implemented here**: APIClient's /// `PairingError.isPrivateOrLocalHost` is internal AND too coarse for the /// tiers (it collapses loopback/Tailscale/RFC1918 into one bucket). /// Duplication noted for the T-iOS-38 dedup pass. /// - `.local` (mDNS) hosts over http are shown the plaintext-LAN notice: they /// resolve to LAN addresses, so the §5.4 "ws:// on an untrusted LAN" row /// applies to them the same way. /// /// §3.4 contract ruling (2026-07-04): the injected probe returns the validated /// `HostEndpoint`; `Host{id: UUID(), name:}` is constructed HERE (id/name are /// not the probe's to know). Production wiring (T-iOS-15) passes /// `runPairingProbe` with the real transports. @MainActor @Observable final class PairingViewModel { /// Everything the pairing flow needs from the network/platform, injected as /// ONE value built by the composition root (`AppEnvironment.probe`). /// /// Why a struct and not three parameters: `AppEnvironment.probe` is handed to /// this VM by `AppCoordinator`, so growing the capability set inside the /// value keeps the composition root the single place they are built — adding /// separately-defaulted parameters instead would silently fall back to /// defaults in production, i.e. a dead hook. /// /// Tests fake results AND assert non-invocation before the user confirms /// (task RED list); `validateToken`/`unregisterPush` default to the /// zero-config behaviour so existing call sites stay one-liners. struct Probe: Sendable { /// Two-step host verification (`runPairingProbe`), carrying an optional /// candidate access token for a host that gates its surface (§1.1). let verifyHost: @Sendable (HostEndpoint, AccessToken?) async -> Result /// One-shot `POST /auth` validation of a candidate token — §1.1's four /// outcomes, or a transport-level failure. let validateToken: @Sendable (HostEndpoint, AccessToken) async -> Result /// Side effect of removing a host: de-register THIS device's APNs token /// on that host (`PushRegistrar.handleHostRemoved`). Failure is the /// registrar's to log — removal must never be blocked by it. let unregisterPush: @Sendable (HostRegistry.Host) async -> Void init( verifyHost: @escaping @Sendable (HostEndpoint, AccessToken?) async -> Result, /// Unscripted default = "this host has auth disabled", which is the /// zero-config LAN reality and never fabricates an authenticated state. validateToken: @escaping @Sendable (HostEndpoint, AccessToken) async -> Result = { _, _ in .success(.authDisabled) }, unregisterPush: @escaping @Sendable (HostRegistry.Host) async -> Void = { _ in } ) { self.verifyHost = verifyHost self.validateToken = validateToken self.unregisterPush = unregisterPush } } /// Why a `POST /auth` probe never produced one of the four §1.1 outcomes. /// Deliberately payload-free about the token itself (§5.3). enum TokenProbeFailure: Error, Equatable, Sendable { /// Transport-level failure / unexpected status; carries the network /// description only (never the token). case unreachable(String) /// The candidate violated the frozen shape before any I/O — defensive: /// the VM validates first, so this should be unreachable in practice. case malformed } // MARK: - UI state model /// §5.4 warning tiers, decided from scheme + host class (see `warning(for:)`). enum SecurityWarning: Equatable, Sendable { /// https anywhere private-class, or ws→loopback: nothing to warn about. case none /// ws→100.64/10 or `*.ts.net`: WireGuard already encrypts — no /// plaintext warning, an optional positive badge instead. case tailscaleEncrypted /// ws→RFC1918 / link-local / `.local`: NON-blocking notice — keystrokes /// and output are sniffable on the same LAN; prefer `tailscale serve`. case plaintextLAN /// Public host (http AND https alike, §5.4 table): strongest BLOCKING /// warning — anyone who can reach the port gets a shell. case publicHostBlocking var isBlocking: Bool { self == .publicHostBlocking } } /// The parsed-but-not-yet-probed target shown on the confirm page. struct PendingHost: Equatable, Sendable { let endpoint: HostEndpoint let warning: SecurityWarning /// `scheme://host[:port]` via `HostEndpoint`'s single-point derivation /// (browser-Origin serialization) — NEVER hand-assembled. var displayAddress: String { endpoint.originHeader } } /// What the failure UI offers besides the message. enum RecoveryAction: Equatable, Sendable { case retry /// iOS Local Network permission was denied → deep-link to the app's /// Settings pane (its 本地网络 toggle lives there). case openLocalNetworkSettings /// C1 · The host answered **401**, which is ambiguous by design (Origin /// or access token — same status). Offer the one remedy that can be /// applied from the phone: type the access token. Retry stays available /// for the Origin case. case enterAccessToken } struct FailureDisplay: Equatable, Sendable { let message: String let action: RecoveryAction } enum Phase: Equatable { case idle case confirming(PendingHost) case probing(PendingHost) case failed(PendingHost, FailureDisplay) /// C1 · Access-token prompt for a host that answered 401. case awaitingToken(PendingHost) /// C1 · `POST /auth` in flight for the typed candidate. case validatingToken(PendingHost) case paired(HostRegistry.Host) } // MARK: - Observable state private(set) var phase: Phase = .idle /// Inline rejection copy for invalid scan/manual input (idle-state error). private(set) var inputRejection: String? /// Editable name shown on the confirm page; defaults to the endpoint host /// and falls back to it when the user clears the field. var hostName = "" /// Explicit user acknowledgement for the blocking public-host warning. var hasAcknowledgedPublicRisk = false /// Set when confirm was attempted on a blocking warning WITHOUT the /// acknowledgement — the UI highlights the ack control. private(set) var needsPublicRiskAcknowledgement = false /// Navigate signal: set exactly once when pairing completes (T-iOS-15 /// observes it to move on to the session list). private(set) var pairedHost: HostRegistry.Host? /// Inline copy under the token field (wrong token / rate-limited / this host /// has no auth at all / shape violation). NEVER echoes the token itself. private(set) var tokenRejection: String? /// C1 · Already-paired hosts, for the manage section (token update + remove). /// Loaded explicitly by the screen — pairing itself never needs it. private(set) var pairedHosts: [HostRegistry.Host] = [] /// Explicit store-failure copy for the manage section (never a silent /// empty list hiding a broken keychain). private(set) var hostsErrorMessage: String? // MARK: - Dependencies (not observed) @ObservationIgnored private let store: any HostStore @ObservationIgnored private let probe: Probe /// The token validated by `POST /auth` for the host being paired, held in /// memory ONLY until the host record is written (Keychain via `HostStore`). /// nil = no token (LAN default, or the host reported auth disabled). @ObservationIgnored private var candidateToken: AccessToken? /// C-iOS-3 · Whether a device client certificate is installed. Used to gate /// the probe for tunnel hosts (mTLS-only). Injected so tests control it; /// production reads the keychain. Defaulted so existing call sites compile. @ObservationIgnored private let isDeviceCertInstalled: @Sendable () -> Bool init( store: any HostStore, probe: Probe, // C1 · a value now (three capabilities), no longer a closure isDeviceCertInstalled: @escaping @Sendable () -> Bool = { KeychainClientIdentityStore().hasInstalledIdentity() } ) { self.store = store self.probe = probe self.isDeviceCertInstalled = isDeviceCertInstalled } // MARK: - Input boundaries (untrusted, validated via HostEndpoint) /// QR scan result (`public/qr.ts` encodes `location.origin`). Strict: the /// payload must already be a full http(s) URL — no scheme inference for /// untrusted external input. func handleScannedCode(_ payload: String) { guard canAcceptNewTarget else { return } let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) guard let url = URL(string: trimmed), let endpoint = HostEndpoint(baseURL: url) else { inputRejection = PairingCopy.scanRejected return } enterConfirming(endpoint) } /// Manually typed URL. The user knows what they typed, but it still goes /// through the SAME confirm state (uniform warning tiers — documented /// decision). Convenience: no `://` → `http://` prefix before parsing. func submitManualURL(_ text: String) { guard canAcceptNewTarget else { return } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { inputRejection = PairingCopy.manualRejected return } let candidate = trimmed.contains(Self.schemeSeparator) ? trimmed : Self.defaultManualScheme + trimmed guard let url = URL(string: candidate), let endpoint = HostEndpoint(baseURL: url) else { inputRejection = PairingCopy.manualRejected return } enterConfirming(endpoint) } /// Back out of confirm/failed to a clean entry state. Never probes. /// Drops the in-memory token candidate too — an abandoned pairing must not /// leave a secret behind for the next target. func cancel() { phase = .idle inputRejection = nil hasAcknowledgedPublicRisk = false needsPublicRiskAcknowledgement = false tokenRejection = nil candidateToken = nil } // MARK: - Confirm → probe → store /// The ONLY way network starts. No-op unless confirming; a blocking /// warning without explicit acknowledgement refuses and flags the UI. func confirmConnect() async { guard case .confirming(let pending) = phase else { return } if pending.warning.isBlocking && !hasAcknowledgedPublicRisk { needsPublicRiskAcknowledgement = true return } await runProbe(for: pending) } /// Re-run the full probe against the same endpoint after a failure. func retry() async { guard case .failed(let pending, _) = phase else { return } await runProbe(for: pending) } private var canAcceptNewTarget: Bool { switch phase { case .idle, .confirming, .failed: return true case .probing, .awaitingToken, .validatingToken, .paired: // Mid-credential-entry counts as busy: a scan landing while the // token prompt is up must not silently retarget the token. return false } } private func enterConfirming(_ endpoint: HostEndpoint) { inputRejection = nil hasAcknowledgedPublicRisk = false needsPublicRiskAcknowledgement = false tokenRejection = nil candidateToken = nil // a new target never inherits the previous token hostName = endpoint.baseURL.host ?? "" phase = .confirming(PendingHost( endpoint: endpoint, warning: Self.warning(for: endpoint) )) } private func runProbe(for pending: PendingHost) async { needsPublicRiskAcknowledgement = false // C-iOS-3 · Tunnel hosts are mTLS-only: refuse to probe (which would // fail at the TLS handshake) until a device certificate is installed. // This is the single choke point both confirmConnect and retry funnel // through, so the gate can't be bypassed via retry(). if Self.isTunnelHost(pending.endpoint), !isDeviceCertInstalled() { phase = .failed(pending, FailureDisplay( message: PairingCopy.deviceCertRequired, action: .retry )) return } phase = .probing(pending) // The candidate token (if any) travels with BOTH probe legs — the HTTP // one as a `Cookie` header via APIClient, the WS one via the // probe-scoped transport the composition root builds (§1.1). switch await probe.verifyHost(pending.endpoint, candidateToken) { case .failure(let error): phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint)) case .success(let endpoint): await storePairedHost(endpoint: endpoint, pending: pending) } } /// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED /// endpoint. A store failure is surfaced explicitly (never swallowed); /// retry re-runs the whole confirm flow. /// /// C1 · Re-pairing the SAME origin updates that host in place (its `id` is /// reused) instead of appending a duplicate. That is what makes "this host /// just got a WEBTERM_TOKEN" recoverable: pair it again, type the token, and /// the existing record — the one every `lastSessionId`/watermark is keyed by /// — gains the token. private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async { let existing = await existingHost(matching: endpoint) let host = HostRegistry.Host( id: existing?.id ?? UUID(), name: resolvedName(for: endpoint, existing: existing), endpoint: endpoint, // A validated candidate wins; otherwise keep whatever the record // already had (re-pairing to fix a name must not wipe a working // credential). `.authDisabled` clears the candidate first, so a // host that reports no auth can never persist a token here. accessToken: candidateToken ?? existing?.accessToken ) do { _ = try await store.upsert(host) } catch { phase = .failed(pending, FailureDisplay( message: PairingCopy.storeFailed, action: .retry )) return } candidateToken = nil // persisted — drop the in-memory copy pairedHost = host phase = .paired(host) } /// The already-paired host at the same origin, or nil. A store read failure /// degrades to nil (pair as new) rather than blocking the pairing. private func existingHost(matching endpoint: HostEndpoint) async -> HostRegistry.Host? { let hosts = try? await store.loadAll() return hosts?.first { $0.endpoint.originHeader == endpoint.originHeader } } /// User-typed name wins; an untouched field keeps the existing host's name /// (re-pairing must not rename "MacBook" to "192.168.1.5"). private func resolvedName( for endpoint: HostEndpoint, existing: HostRegistry.Host? ) -> String { let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines) let derived = endpoint.baseURL.host ?? endpoint.originHeader if trimmed.isEmpty || trimmed == derived { return existing?.name ?? derived } return trimmed } // MARK: - Access token (§1.1: POST /auth is a one-shot pairing probe) /// Failure page → token prompt. Only from a 401-shaped failure, which is the /// only failure whose remedy might be a token. func beginAccessTokenEntry() { guard case .failed(let pending, let failure) = phase, failure.action == .enterAccessToken else { return } tokenRejection = nil phase = .awaitingToken(pending) } /// Token prompt → back to the confirm page (the URL is still fine; the user /// may prefer to fix `ALLOWED_ORIGINS` instead). func cancelAccessTokenEntry() { guard case .awaitingToken(let pending) = phase else { return } tokenRejection = nil candidateToken = nil phase = .confirming(pending) } /// Validate a typed token against the host, then re-run the host probe with /// it. Shape is checked BEFORE any I/O (§1.1 charset/length is the server's /// own rule, so a shape violation can never be the right token). /// /// The four §1.1 outcomes are all handled explicitly, and `authDisabled` /// deliberately does NOT persist anything: a 204 without `Set-Cookie` means /// the host never enabled auth, so claiming "authenticated" would be a lie /// and storing the token would gate nothing. func submitAccessToken(_ raw: String) async { guard case .awaitingToken(let pending) = phase else { return } let token: AccessToken do { token = try AccessToken(validating: raw) } catch let error as AccessTokenError { tokenRejection = PairingCopy.tokenShapeRejected(error) return } catch { tokenRejection = PairingCopy.tokenShapeUnknown return } tokenRejection = nil phase = .validatingToken(pending) switch await probe.validateToken(pending.endpoint, token) { case .success(.valid): candidateToken = token await runProbe(for: pending) // re-verify, now authenticated case .success(.authDisabled): candidateToken = nil tokenRejection = PairingCopy.tokenNotRequired phase = .awaitingToken(pending) case .success(.invalidToken): tokenRejection = PairingCopy.tokenInvalid phase = .awaitingToken(pending) case .success(.rateLimited): tokenRejection = PairingCopy.tokenRateLimited phase = .awaitingToken(pending) case .failure(.unreachable(let underlying)): tokenRejection = PairingCopy.hostUnreachable(underlying) phase = .awaitingToken(pending) case .failure(.malformed): tokenRejection = PairingCopy.tokenShapeUnknown phase = .awaitingToken(pending) } } // MARK: - Paired-host management (C1 · the removal path `handleHostRemoved` lacked) /// Load the manage section's host list. Explicit error copy on failure. func loadPairedHosts() async { do { pairedHosts = try await store.loadAll() hostsErrorMessage = nil } catch { hostsErrorMessage = PairingCopy.hostsLoadFailed } } /// Remove a paired host: de-register this device's APNs token on it, then /// delete the record (which takes its access token with it — the token is a /// field of the record, so no orphaned secret can survive). /// /// ORDER MATTERS: the de-registration POST goes out FIRST, while the record /// (and therefore the host's access token, which the request needs to get /// past a token gate) still exists. A failing de-registration never blocks /// the removal — the user asked for the host to be gone, and the server also /// prunes tokens itself on an APNs 410. func removeHost(id: UUID) async { guard let host = pairedHosts.first(where: { $0.id == id }) else { return } await probe.unregisterPush(host) do { pairedHosts = try await store.remove(id: id) hostsErrorMessage = nil } catch { hostsErrorMessage = PairingCopy.hostRemoveFailed } } // MARK: - PairingError → copy + action (task RED list, one case each) /// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked /// client cert at the TLS layer; URLSession surfaces that as /// secureConnectionFailed / connection-reset → `PairingError.classify` maps /// it to `.tlsFailure` ("server cert invalid"), which is the WRONG diagnosis /// for an mTLS tunnel host. Re-map that one case to the device-cert copy; /// everything else falls through to the per-case mapping. static func display(for error: PairingError, endpoint: HostEndpoint) -> FailureDisplay { if case .tlsFailure = error, isTunnelHost(endpoint) { return FailureDisplay(message: PairingCopy.clientCertRejected, action: .retry) } return display(for: error) } static func display(for error: PairingError) -> FailureDisplay { switch error { case .localNetworkDenied: return FailureDisplay( message: PairingCopy.localNetworkDenied, action: .openLocalNetworkSettings ) case .hostUnreachable(let underlying): return FailureDisplay( message: PairingCopy.hostUnreachable(underlying), action: .retry ) case .httpOkButNotWebTerminal: return FailureDisplay(message: PairingCopy.notWebTerminal, action: .retry) case .originRejected(let hint): // The probe already derived the complete actionable copy from // endpoint.originHeader — surface it VERBATIM, never re-derive. // // C1 · The action is `.enterAccessToken`, not `.retry`: this case now // also carries the AMBIGUOUS 401 (Origin *or* token — the server // answers the same status for both, see `unauthorizedPairingHint`), // and typing a token is the only remedy reachable from the phone. // The failure view keeps a retry button alongside it, so the pure // Origin case (the 403 on the guarded kill) loses nothing. return FailureDisplay(message: hint, action: .enterAccessToken) case .atsBlocked(let host): return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry) case .tlsFailure: return FailureDisplay(message: PairingCopy.tlsFailure, action: .retry) case .timeout: return FailureDisplay(message: PairingCopy.timeout, action: .retry) } } // MARK: - §5.4 warning tiers /// Decide the confirm-page warning from scheme + host class. Public hosts /// block regardless of scheme (§5.4 table: https is included in the /// public-host confirm warning); otherwise https clears every notice. static func warning(for endpoint: HostEndpoint) -> SecurityWarning { // C-iOS-3 · A *.terminal.yaojia.wang tunnel host is gated by the device // client certificate (mTLS), so the "anyone who can reach the port gets // a shell" blocking warning is FALSE here and would only deter the // intended flow. The real gate is the cert-install check in runProbe. // Genuinely public NON-tunnel hosts still hit .publicHostBlocking below. if isTunnelHost(endpoint) { return .none } let hostClass = classifyHost(endpoint.baseURL.host ?? "") if hostClass == .publicHost { return .publicHostBlocking } if endpoint.baseURL.scheme?.lowercased() == Self.httpsScheme { return .none } switch hostClass { case .loopback: return .none case .tailscale: return .tailscaleEncrypted case .privateLAN: return .plaintextLAN case .publicHost: return .publicHostBlocking // unreachable; keeps the switch total } } /// Address classes relevant to §5.4. NOTE: near-duplicate of APIClient's /// internal `isPrivateOrLocalHost` (finer-grained here) — T-iOS-38 dedup. enum HostClass: Equatable, Sendable { case loopback case tailscale case privateLAN case publicHost } static func classifyHost(_ rawHost: String) -> HostClass { let host = rawHost.lowercased() .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) // IPv6 brackets if host == Self.localhostName { return .loopback } if host.hasSuffix(Self.tailscaleMagicDNSSuffix) { return .tailscale } if host.hasSuffix(Self.mdnsSuffix) { return .privateLAN } if let octets = ipv4Octets(host) { return classifyIPv4(octets) } if host.contains(":") { return classifyIPv6(host) } return .publicHost } private static func classifyIPv4(_ octets: [Int]) -> HostClass { switch (octets[0], octets[1]) { case (127, _): return .loopback case (10, _), (192, 168), (169, 254): return .privateLAN // RFC1918 10/8, 192.168/16 · link-local 169.254/16 case (172, 16...31): return .privateLAN // RFC1918 172.16/12 case (100, 64...127): return .tailscale // CGNAT 100.64/10 default: return .publicHost } } private static func classifyIPv6(_ host: String) -> HostClass { if host == Self.ipv6Loopback { return .loopback } let isLinkLocal = host.hasPrefix(Self.ipv6LinkLocalPrefix) let isULA = host.hasPrefix("fc") || host.hasPrefix("fd") // fc00::/7 return (isLinkLocal || isULA) ? .privateLAN : .publicHost } private static func ipv4Octets(_ host: String) -> [Int]? { let parts = host.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == Self.ipv4OctetCount else { return nil } let octets = parts.compactMap { Int($0) } guard octets.count == Self.ipv4OctetCount, octets.allSatisfy({ Self.ipv4OctetRange.contains($0) }) else { return nil } return octets } /// C-iOS-3 · A native mTLS reverse-tunnel host (`.terminal.yaojia.wang`). /// These reach a loopback base app through the VPS and are protected ONLY by /// the device client certificate — hence the softened warning + the /// cert-install gate + the client-cert-rejected re-classification. static func isTunnelHost(_ endpoint: HostEndpoint) -> Bool { (endpoint.baseURL.host ?? "").lowercased().hasSuffix(tunnelZoneSuffix) } // MARK: - Named constants (no magic values, plan §4) private static let tunnelZoneSuffix = ".terminal.yaojia.wang" private static let schemeSeparator = "://" private static let defaultManualScheme = "http://" private static let httpsScheme = "https" private static let localhostName = "localhost" private static let tailscaleMagicDNSSuffix = ".ts.net" private static let mdnsSuffix = ".local" private static let ipv6Loopback = "::1" private static let ipv6LinkLocalPrefix = "fe80" private static let ipv4OctetCount = 4 private static let ipv4OctetRange = 0...255 }