App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
639 lines
28 KiB
Swift
639 lines
28 KiB
Swift
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<HostEndpoint, PairingError>
|
|
/// 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<AccessTokenProbeResult, TokenProbeFailure>
|
|
/// 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<HostEndpoint, PairingError>,
|
|
/// 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<AccessTokenProbeResult, TokenProbeFailure> = { _, _ 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 (`<name>.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
|
|
}
|