diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 321a66d93872..2b3d51bc25cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,13 @@ jobs: working-directory: agent-chat run: bun test/claude-environment.test.ts + # The committed control-plane wire types (Swift + TS) must be exactly + # what quicktype regenerates from schemas/control-plane/; fails on any + # hand edit or schema change without regen. Needs bun (bunx quicktype), + # set up above. + - name: Validate control-plane generated types + run: ./scripts/check-control-plane-types.sh + - name: Set up Python 3.9 for nightly prune compatibility uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift index bf26d0c0bea8..d41544e9e17e 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift @@ -100,10 +100,20 @@ public struct CmxPairingQRCode: Sendable { guard let identity = encodableIrohIdentity(of: ticket) else { return nil } - items = [ + var irohItems = [ "v=\(Self.irohVersion)", "i=\(identity.endpointID)" ] + // The Mac device id rides along so the decoded ticket can name the + // peer intent (`expectedPeerDeviceID`) the irx transport requires + // before any dial. Endpoint-only tickets decode with an empty + // device id, and a fresh pairing then has no post-handshake source + // for it, so every injected physical-device auto-pair fails + // `missingPeerIntent` without this field. + if let macDeviceID = normalizedNonEmpty(ticket.macDeviceID) { + irohItems.append("d=\(percentEncodeQueryValue(macDeviceID))") + } + items = irohItems case .legacyPrivateNetworkCompatibility: guard let routes = encodableTailscaleRoutes(of: ticket) else { return nil @@ -307,13 +317,19 @@ private extension CmxPairingQRCode { /// Decode the v3 endpoint-only Iroh grammar. func decodeIroh(_ components: URLComponents) throws -> CmxAttachTicket { let items = components.queryItems ?? [] - guard items.count == 2, + // `d` (the Mac device id) is optional so pre-existing endpoint-only + // URLs keep decoding; everything else stays exact-cardinality strict. + guard items.count <= 3, + items.allSatisfy({ ["v", "i", "d"].contains($0.name) }), items.filter({ $0.name == "v" }).count == 1, let endpointID = items.first(where: { $0.name == "i" })?.value, items.filter({ $0.name == "i" }).count == 1, + items.filter({ $0.name == "d" }).count <= 1, let identity = try? CmxIrohPeerIdentity(endpointID: endpointID) else { throw MobileSyncPairingPayloadError.invalidURL } + let macDeviceID = items.first(where: { $0.name == "d" })?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let route = try CmxAttachRoute( id: CmxAttachTransportKind.iroh.rawValue, kind: .iroh, @@ -323,7 +339,7 @@ private extension CmxPairingQRCode { let ticket = try CmxAttachTicket( workspaceID: "", terminalID: nil, - macDeviceID: "", + macDeviceID: macDeviceID, macDisplayName: nil, // v3 is intentionally endpoint-only. `nil` means the QR did not // make a compatibility claim, unlike v2's explicit unknown value diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketIrohQRCodeTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketIrohQRCodeTests.swift index 4196d8de1a0e..f79fe9a9ebaa 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketIrohQRCodeTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketIrohQRCodeTests.swift @@ -94,12 +94,16 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute { routeDisclosureMode: .irohIdentityOnly, pairingURLScheme: compactIrohQRTarget )) + // The Mac device id is deliberately part of the minimal grammar: the + // decoded ticket must name the peer intent (`expectedPeerDeviceID`) the + // irx transport requires before it will dial, and a fresh pairing has no + // other source for it. It identifies but never authorizes; admission + // stays the only authority. #expect( pairingURL - == "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)" + == "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)&d=mac-1" ) #expect(!pairingURL.contains("payload=")) - #expect(!pairingURL.contains("mac-1")) #expect(!pairingURL.contains(privateAddress)) #expect(!pairingURL.contains(relayURL)) #expect(!pairingURL.contains(websocketURL)) @@ -118,7 +122,7 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute { ) ) #expect(pairingDecoded.routes == [expectedPairingRoute]) - #expect(pairingDecoded.macDeviceID.isEmpty) + #expect(pairingDecoded.macDeviceID == "mac-1") #expect(pairingDecoded.macDisplayName == nil) #expect(pairingDecoded.macUserID == nil) // Endpoint-only v3 codes intentionally omit compatibility metadata. Keep @@ -146,7 +150,10 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute { ) #expect(pairingURL.utf8.count < beforeURL.utf8.count) #expect(afterModules < beforeModules) - #expect(afterModules <= 41) + // 45 = one QR version above the endpoint-only 41: the `d` device-id field + // buys working irx peer intent for one version step, still far below the + // 57-module compact v1 payload. + #expect(afterModules <= 45) let tailscaleOnly = try CmxAttachTicket( workspaceID: "", @@ -164,3 +171,22 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute { ) } } + +@Test func identityOnlyQRDecodeToleratesLegacyURLsWithoutDeviceID() throws { + // Pre-`d` encoders mint exactly `v` + `i`. Those URLs must keep decoding + // (empty device id), and a duplicated or unknown parameter still fails. + let base = "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)" + let legacy = try #require(URLComponents(string: base)) + let decoded = try CmxPairingQRCode().decode(legacy) + #expect(decoded.macDeviceID.isEmpty) + #expect(decoded.routes.count == 1) + + let doubledDevice = try #require(URLComponents(string: base + "&d=a&d=b")) + #expect(throws: MobileSyncPairingPayloadError.invalidURL) { + _ = try CmxPairingQRCode().decode(doubledDevice) + } + let unknownParameter = try #require(URLComponents(string: base + "&x=1")) + #expect(throws: MobileSyncPairingPayloadError.invalidURL) { + _ = try CmxPairingQRCode().decode(unknownParameter) + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift index c8c15b6f16da..d37c3ae1a5eb 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift @@ -250,6 +250,25 @@ public final class AuthCoordinator { await checkExistingSession() } + /// Supersede parked timed-out auth phases before an explicit interactive + /// attempt (a pairing attempt, a tapped retry). One Stack call hung on a + /// dead pooled connection times its phase out and dampens it for 30s; + /// without this, the very next user action fails in milliseconds with + /// ``AuthError/timedOut`` even though a fresh request would succeed. The + /// timed-out operation was already cancelled at its deadline and its + /// writes are dropped by the sign-in chokepoint, so releasing its slot is + /// safe; live operations keep their exclusivity. + public func supersedeTimedOutAuthPhases() async { + // Both dampers: sign-in exchanges park in the phase registry, and + // token-touching work (access-token fetches, session probes) parks in + // the coordinator's own timed-out states. Token-touching phases allow + // concurrent actives by construction (write safety is generational, + // via finishTokenTouchingPhase), so dropping the damper alone is + // sufficient there. + await phaseTimeoutRegistry.supersedeTimedOutPhases() + timedOutTokenTouchingPhaseStates.removeAll() + } + // MARK: - Sign-in flows /// Send a sign-in code to `email`, or run the debug `42` shortcut. diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhaseTimeoutRegistry.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhaseTimeoutRegistry.swift index 9ead279438d9..a4ea506a1a3e 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhaseTimeoutRegistry.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhaseTimeoutRegistry.swift @@ -57,4 +57,22 @@ actor AuthPhaseTimeoutRegistry { activePhases[key] = nil timedOutPhases[key] = nil } + + /// Supersede every parked timed-out phase for an explicit interactive + /// attempt. A timed-out phase's operation was already cancelled at its + /// deadline and the sign-in write chokepoint drops a cancelled flow's + /// token writes, so the damper's only remaining job is suppressing + /// AUTOMATIC retry hammering; an explicit user action (a pairing attempt, + /// a tapped retry) is entitled to reclaim the phase immediately. Only the + /// timed-out operation's slot is released: a live, untimed-out operation + /// keeps refusing concurrent begins exactly as before. + func supersedeTimedOutPhases() { + for (key, state) in timedOutPhases { + activePhases[key]?.remove(state.id) + if activePhases[key]?.isEmpty == true { + activePhases[key] = nil + } + } + timedOutPhases.removeAll() + } } diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift index 21a9312796fb..45dc173151e2 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift @@ -254,6 +254,37 @@ import Testing await waitUntilTokenTouchingCleanupFinished(coordinator) } + @Test func explicitSupersedeUnblocksTimedOutAccessTokenPhaseImmediately() async throws { + // One launch-time Stack call hung on a dead pooled connection arms + // the token-touching damper; a pairing attempt seconds later must be + // able to supersede it instead of fast-failing for the damper's + // remaining window. + let clock = ManualTestClock() + let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") + let client = HangingLaunchTokenProbeAuthClient(user: user) + let coordinator = makeCoordinator(client: client, clock: clock) + + let first = Task { try await coordinator.accessToken() } + await client.accessTokenDidStart() + await clock.waitUntilSleepers() + clock.advance(by: Self.testTimeouts.network) + await #expect(throws: AuthError.timedOut) { try await first.value } + + // Damper armed (default 30s window): an automatic retry fast-fails. + let second = Task { try await coordinator.accessToken() } + await #expect(throws: AuthError.timedOut) { try await second.value } + #expect(await client.accessStartCount == 1) + + // An explicit interactive attempt supersedes and runs a fresh probe. + await coordinator.supersedeTimedOutAuthPhases() + await client.releaseHangingAccessTokenProbe() + await waitUntilTokenTouchingCleanupFinished(coordinator) + await #expect(throws: AuthError.networkError) { + try await coordinator.accessToken() + } + #expect(await client.accessStartCount == 2) + } + @Test func timedOutAccessTokenPhaseRetriesAfterBoundedReset() async throws { let clock = ManualTestClock() let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift index 9f3068f099e0..dbc8bc48255f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift @@ -114,7 +114,7 @@ extension CmxIrohClientRuntime { } static func isConnectivity(_ error: any Error) -> Bool { - (error as? CmxIrohTrustBrokerClientError) == .connectivity + (error as? CmxIrohTrustBrokerClientError)?.isConnectivity == true } /// Failures that may fall back to the verified offline policy cache. diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift index e3d87a27139a..5a288757703a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift @@ -531,6 +531,6 @@ public actor CmxIrohOnlineAdmissionRegistry { } private static func isConnectivity(_ error: any Error) -> Bool { - (error as? CmxIrohTrustBrokerClientError) == .connectivity + (error as? CmxIrohTrustBrokerClientError)?.isConnectivity == true } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift index a5fb93af343a..292d9f91b5a8 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift @@ -1128,6 +1128,6 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { } private static func isConnectivity(_ error: any Error) -> Bool { - (error as? CmxIrohTrustBrokerClientError) == .connectivity + (error as? CmxIrohTrustBrokerClientError)?.isConnectivity == true } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift index e89868350e34..ac4768229cd1 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift @@ -812,8 +812,11 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { // and indistinguishable from an unreachable broker for every // caller policy (retry, cached-policy fallback, verified-policy // preservation), so classify it as connectivity, not as a - // definitive authentication failure. - throw CmxIrohTrustBrokerClientError.connectivity + // definitive authentication failure. A URL-loading failure from + // the source's own refresh call keeps its code for attribution. + throw CmxIrohTrustBrokerClientError.connectivity( + (error as? URLError).map(CmxIrohBrokerConnectivityCause.init) + ) } guard let pair = capturedPair else { throw CmxIrohTrustBrokerClientError.missingAuthentication @@ -838,7 +841,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { } catch is CancellationError { throw CancellationError() } catch { - throw CmxIrohTrustBrokerClientError.connectivity + throw CmxIrohTrustBrokerClientError.connectivity( + (error as? URLError).map(CmxIrohBrokerConnectivityCause.init) + ) } guard let recovered else { throw error } return try await performAuthenticatedRequest( @@ -923,7 +928,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { do { (data, response) = try await transport.data(for: request) } catch let error as URLError where Self.isConnectivityFailure(error.code) { - throw CmxIrohTrustBrokerClientError.connectivity + throw CmxIrohTrustBrokerClientError.connectivity( + CmxIrohBrokerConnectivityCause(error) + ) } guard let http = response as? HTTPURLResponse else { throw CmxIrohTrustBrokerClientError.nonHTTPResponse diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClientError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClientError.swift index 633d4e7b8199..d4ef7e1f66e5 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClientError.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClientError.swift @@ -1,4 +1,52 @@ public import CMUXMobileCore +public import Foundation + +/// Underlying URL-loading failure carried by connectivity-class broker +/// errors, so journals and caller retry policies can distinguish a dead +/// kept-alive connection (NSURLErrorNetworkConnectionLost) from DNS loss, +/// timeouts, or a token source that could not produce a coherent pair. +public struct CmxIrohBrokerConnectivityCause: Equatable, Sendable, + CustomStringConvertible +{ + /// NSURLErrorDomain code, e.g. -1005. + public let urlErrorCode: Int + + public init(urlErrorCode: Int) { + self.urlErrorCode = urlErrorCode + } + + public init(_ error: URLError) { + self.init(urlErrorCode: error.code.rawValue) + } + + /// Whether this is the connection-reuse failure class: a pooled + /// keep-alive connection the server closed while it sat idle, surfaced + /// only when the next request's first read fails. URLSession never + /// transparently retries a request whose body bytes were already written + /// (Apple QA1941), so idempotent callers retry once themselves; the + /// failed attempt already purged the dead pooled connection. + public var isConnectionReuseFailure: Bool { + urlErrorCode == URLError.Code.networkConnectionLost.rawValue + } + + public var description: String { "\(symbolicName)(\(urlErrorCode))" } + + private var symbolicName: String { + switch URLError.Code(rawValue: urlErrorCode) { + case .timedOut: "timedOut" + case .cannotFindHost: "cannotFindHost" + case .cannotConnectToHost: "cannotConnectToHost" + case .networkConnectionLost: "networkConnectionLost" + case .dnsLookupFailed: "dnsLookupFailed" + case .notConnectedToInternet: "notConnectedToInternet" + case .internationalRoamingOff: "internationalRoamingOff" + case .callIsActive: "callIsActive" + case .dataNotAllowed: "dataNotAllowed" + case .cannotLoadFromNetwork: "cannotLoadFromNetwork" + default: "urlError" + } + } +} /// Failures at the authenticated HTTP trust-broker boundary. public enum CmxIrohTrustBrokerClientError: @@ -6,8 +54,11 @@ public enum CmxIrohTrustBrokerClientError: Equatable, Sendable { - /// The authenticated broker could not be reached through the current network. - case connectivity + /// The authenticated broker could not be reached through the current + /// network. Carries the underlying URL-loading classification when one + /// exists; a `nil` cause is a token source that could not read a + /// coherent credential pair for a non-network reason. + case connectivity(CmxIrohBrokerConnectivityCause?) case invalidBaseURL case missingAuthentication case invalidAuthentication @@ -87,4 +138,43 @@ public enum CmxIrohTrustBrokerClientError: guard case let .rateLimited(_, retryAfterSeconds) = self else { return nil } return retryAfterSeconds } + + /// Whether this is any connectivity-class failure, regardless of the + /// underlying cause detail. Callers deciding retry or cached-state + /// policy match on this instead of value equality, which would treat + /// differently-attributed connectivity failures as distinct. + public var isConnectivity: Bool { + if case .connectivity = self { return true } + return false + } +} + +extension CmxIrohTrustBrokerClientError: CustomStringConvertible { + /// Journal-stable rendering: identical to the previously synthesized + /// text for every case, except that an attributed connectivity failure + /// appends its URL-loading cause, e.g. + /// `connectivity(networkConnectionLost(-1005))`. + public var description: String { + switch self { + case .connectivity(nil): + "connectivity" + case let .connectivity(cause?): + "connectivity(\(cause))" + case .invalidBaseURL: + "invalidBaseURL" + case .missingAuthentication: + "missingAuthentication" + case .invalidAuthentication: + "invalidAuthentication" + case .nonHTTPResponse: + "nonHTTPResponse" + case let .rateLimited(code, retryAfterSeconds): + "rateLimited(code: \(String(describing: code)), " + + "retryAfterSeconds: \(retryAfterSeconds))" + case let .rejected(statusCode, code): + "rejected(statusCode: \(statusCode), code: \(String(describing: code)))" + case .invalidResponse: + "invalidResponse" + } + } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityEngineTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityEngineTests.swift index 5c28d9cbfad5..e4e530ad77bc 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityEngineTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityEngineTests.swift @@ -755,7 +755,7 @@ private actor ScriptedConnectivityAuthority: CmxConnectivityAuthorityServing { ) async throws -> CmxConnectivitySyncResponse { observedKnownRevisions.append(knownRevision) guard !responses.isEmpty else { - throw CmxIrohTrustBrokerClientError.connectivity + throw CmxIrohTrustBrokerClientError.connectivity(nil) } return responses.removeFirst() } @@ -778,7 +778,7 @@ private actor InitialThenFailingConnectivityAuthority: CmxConnectivityAuthorityS if knownRevision == nil { return initial } - throw CmxIrohTrustBrokerClientError.connectivity + throw CmxIrohTrustBrokerClientError.connectivity(nil) } func callCount() -> Int { calls } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCooldownTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCooldownTests.swift index 6ebaa7eaddca..71e9a9573afd 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCooldownTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCooldownTests.swift @@ -101,7 +101,7 @@ struct CmxIrohBrokerCooldownTests { for: CmxIrohTrustBrokerClientError.rejected(statusCode: 503, code: nil) ) == nil) #expect(CmxIrohBrokerCooldown.directiveSeconds( - for: CmxIrohTrustBrokerClientError.connectivity + for: CmxIrohTrustBrokerClientError.connectivity(nil) ) == nil) } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift index fad3d4506842..77b5549683a4 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift @@ -183,7 +183,7 @@ extension CmxIrohClientRuntimeTests { binding: discovery.bindings[0], discovery: discovery, relay: relay, - registrationError: CmxIrohTrustBrokerClientError.connectivity + registrationError: CmxIrohTrustBrokerClientError.connectivity(nil) ) let recorder = ClientRuntimeTestRecorder() let runtime = try CmxIrohClientRuntime( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift index a31f898a126e..7d48a8ea7caf 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift @@ -148,7 +148,7 @@ extension CmxIrohClientRuntimeTests { discovery: fixture.discovery, relay: fixture.relayResponse(), discoveryErrorsByCount: [ - 2: CmxIrohTrustBrokerClientError.connectivity, + 2: CmxIrohTrustBrokerClientError.connectivity(nil), ], discoveryHook: { count in if count == 2 { await secondDiscovery.waitOnce() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift index 47d58effaf44..c940cf2cfc6e 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift @@ -178,7 +178,7 @@ struct CmxIrohClientRuntimeTests { binding: localBinding, discoveries: [rejectedRevision], relay: relay, - registrationError: .connectivity + registrationError: .connectivity(nil) ), configuration: configuration, pendingRevocations: CmxIrohPendingRevocationOutbox( @@ -188,7 +188,7 @@ struct CmxIrohClientRuntimeTests { now: { fixture.now } ) - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { + await #expect(throws: CmxIrohTrustBrokerClientError.connectivity(nil)) { try await runtime.start() } } @@ -553,7 +553,7 @@ struct CmxIrohClientRuntimeTests { discovery: fixture.discovery, relay: fixture.relayResponse(), discoveryErrorsByCount: [ - 2: CmxIrohTrustBrokerClientError.connectivity, + 2: CmxIrohTrustBrokerClientError.connectivity(nil), ] ) let recorder = ClientRuntimeTestRecorder() @@ -835,7 +835,7 @@ struct CmxIrohClientRuntimeTests { let fixture = try ClientRuntimeTestFixture() let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let store = TestSecureCredentialStore() - let connectivity = CmxIrohTrustBrokerClientError.connectivity + let connectivity = CmxIrohTrustBrokerClientError.connectivity(nil) let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, @@ -952,7 +952,7 @@ struct CmxIrohClientRuntimeTests { discovery: fixture.discovery, relay: fixture.relayResponse(), discoveryErrorsByCount: [ - 2: CmxIrohTrustBrokerClientError.connectivity, + 2: CmxIrohTrustBrokerClientError.connectivity(nil), ] ) let runtime = try CmxIrohClientRuntime( @@ -1337,7 +1337,7 @@ struct CmxIrohClientRuntimeTests { binding: fixture.binding, discovery: fixture.discovery, relay: fixture.relayResponse(), - revokeError: CmxIrohTrustBrokerClientError.connectivity + revokeError: CmxIrohTrustBrokerClientError.connectivity(nil) ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory( @@ -1352,7 +1352,7 @@ struct CmxIrohClientRuntimeTests { now: { fixture.now } ) - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { + await #expect(throws: CmxIrohTrustBrokerClientError.connectivity(nil)) { try await runtime.start() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift index cd49a7c3705f..3282eacac542 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift @@ -164,7 +164,7 @@ extension CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - subsequentRegistrationErrors: [.connectivity, .connectivity] + subsequentRegistrationErrors: [.connectivity(nil), .connectivity(nil)] ) let clock = HostRegistrationRenewalClock(now: now) let runtime = CmxIrohHostRuntime( @@ -205,7 +205,7 @@ extension CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - subsequentRegistrationErrors: [.connectivity] + subsequentRegistrationErrors: [.connectivity(nil)] ) let clock = HostRegistrationRenewalClock(now: now) let runtime = CmxIrohHostRuntime( @@ -232,7 +232,7 @@ extension CmxIrohHostRuntimeTests { await broker.waitForRegistrationCount(3) await clock.waitUntilSleepCount(3) - await broker.enqueueSubsequentRegistrationError(.connectivity) + await broker.enqueueSubsequentRegistrationError(.connectivity(nil)) await runtime.requestRegistrationRefresh() await broker.waitForRegistrationCount(4) await clock.waitUntilSleepCount(4) @@ -514,7 +514,7 @@ extension CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity(nil) ) let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift index 045738364aeb..95ba402307e7 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift @@ -406,7 +406,7 @@ extension CmxIrohHostRuntimeTests { broker: TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity(nil) ), configuration: fixture.configuration( cachedHostPolicy: try cachedFixture.policy() @@ -435,7 +435,7 @@ extension CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity(nil) ) let runtime = CmxIrohHostRuntime( factory: factory, @@ -472,7 +472,7 @@ extension CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: changedBinding, discovery: fixture.discovery, - discoveryError: .connectivity + discoveryError: .connectivity(nil) ) let runtime = CmxIrohHostRuntime( factory: factory, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift index 1a25463a0ade..fac5ad8a5aeb 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift @@ -96,7 +96,7 @@ struct CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity(nil) ) let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory( @@ -220,7 +220,7 @@ struct CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity(nil) ) let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory( @@ -263,7 +263,7 @@ struct CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity(nil) ) let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -311,7 +311,7 @@ struct CmxIrohHostRuntimeTests { let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - revokeError: .connectivity + revokeError: .connectivity(nil) ) let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory( @@ -323,7 +323,7 @@ struct CmxIrohHostRuntimeTests { handleTransport: { session, _ in await session.close() } ) - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { + await #expect(throws: CmxIrohTrustBrokerClientError.connectivity(nil)) { try await runtime.start() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift index 102907f98f3c..6fdb84fdc17c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift @@ -38,7 +38,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { @Test func connectivityAllowsLocallyValidGrantOffline() async throws { let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [.failure(.connectivity)]) + let broker = OnlineAdmissionBroker(responses: [.failure(.connectivity(nil))]) let registry = fixture.registry(broker: broker) let authorization = await registry.authorizePairGrant( @@ -99,7 +99,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { let fixture = try OnlineAdmissionFixture() let broker = OnlineAdmissionBroker(responses: [ .success(try fixture.discovery(includeInitiator: false)), - .failure(.connectivity), + .failure(.connectivity(nil)), ]) let registry = fixture.registry(broker: broker) @@ -109,7 +109,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { authenticatedPeerID: fixture.initiator.endpointID ) == .denied ) - await broker.replaceResponses([.failure(.connectivity)]) + await broker.replaceResponses([.failure(.connectivity(nil))]) #expect( await registry.authorizePairGrant( fixture.grant(), @@ -226,7 +226,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { let clock = OnlineAdmissionManualClock(now: fixture.now) let broker = OnlineAdmissionBroker(responses: [ .success(try fixture.discovery()), - .failure(.connectivity), + .failure(.connectivity(nil)), ]) let registry = fixture.registry(broker: broker, clock: clock) let lease = try #require( @@ -406,8 +406,8 @@ extension CmxIrohOnlineAdmissionRegistryTests { let fixture = try OnlineAdmissionFixture(grantLifetime: 31) let clock = OnlineAdmissionManualClock(now: fixture.now) let broker = OnlineAdmissionBroker(responses: [ - .failure(.connectivity), - .failure(.connectivity), + .failure(.connectivity(nil)), + .failure(.connectivity(nil)), ]) let registry = fixture.registry(broker: broker, clock: clock) let lease = try #require( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift index 12fe8df70db6..49be1ca3e684 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift @@ -59,8 +59,8 @@ extension CmxIrohOnlineAdmissionRegistryTests { let fixture = try OnlineAdmissionFixture() let clock = OnlineAdmissionManualClock(now: fixture.now) let broker = OnlineAdmissionBroker(responses: [ - .failure(.connectivity), - .failure(.connectivity), + .failure(.connectivity(nil)), + .failure(.connectivity(nil)), ]) let registry = fixture.registry(broker: broker, clock: clock) let pair = try fixture.offlinePair(initiatorLifetime: 90, acceptorLifetime: 31) @@ -154,7 +154,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { let pair = try fixture.offlinePair() #expect(await registry.authorizeOfflinePair(pair) == .denied) - await broker.replaceResponses([.failure(.connectivity)]) + await broker.replaceResponses([.failure(.connectivity(nil))]) #expect(await registry.authorizeOfflinePair(pair) == .denied) #expect(await broker.callCount() == 1) } @@ -314,7 +314,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { func connectivityAfterPolicyUpdateCannotAdmitStaleAuthority() async throws { let fixture = try OnlineAdmissionFixture() let broker = OnlineAdmissionBroker( - responses: [.failure(.connectivity)], + responses: [.failure(.connectivity(nil))], suspended: true ) let registry = fixture.registry(broker: broker) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPendingRevocationOutboxTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPendingRevocationOutboxTests.swift index f3f0e378c64f..9e8c43910009 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPendingRevocationOutboxTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPendingRevocationOutboxTests.swift @@ -29,7 +29,7 @@ struct CmxIrohPendingRevocationOutboxTests { } @Test(arguments: [ - CmxIrohTrustBrokerClientError.connectivity, + CmxIrohTrustBrokerClientError.connectivity(nil), .rejected(statusCode: 503, code: "unavailable"), ]) func transientFailureRetainsPendingRevocation( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift index ae617b096acb..9222623698a5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift @@ -165,7 +165,7 @@ extension CmxIrohRegistryContextProviderTests { let broker = TestIrohRegistryBroker( discovery: discovery, pairGrantResponses: [], - discoveryError: CmxIrohTrustBrokerClientError.connectivity + discoveryError: CmxIrohTrustBrokerClientError.connectivity(nil) ) let provider = CmxIrohRegistryContextProvider( supervisor: try await fixture.activeSupervisor(), @@ -210,7 +210,7 @@ extension CmxIrohRegistryContextProviderTests { let broker = TestIrohRegistryBroker( discovery: discovery, pairGrantResponses: [], - pairGrantError: CmxIrohTrustBrokerClientError.connectivity + pairGrantError: CmxIrohTrustBrokerClientError.connectivity(nil) ) let provider = CmxIrohRegistryContextProvider( supervisor: try await fixture.activeSupervisor(), diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientAuthRecoveryTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientAuthRecoveryTests.swift index 782bc7e0a1e2..1abccc929dbf 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientAuthRecoveryTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientAuthRecoveryTests.swift @@ -215,7 +215,7 @@ struct CmxIrohTrustBrokerClientAuthRecoveryTests { @Test func cachedPolicyRecoveryFailsClosedForAuthRejections() { #expect(CmxIrohClientRuntime.recoversWithCachedPolicy( - CmxIrohTrustBrokerClientError.connectivity + CmxIrohTrustBrokerClientError.connectivity(nil) )) #expect(!CmxIrohClientRuntime.recoversWithCachedPolicy( CmxIrohTrustBrokerClientError.rejected( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientNetworkTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientNetworkTests.swift index 4355b42127b9..074596eef849 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientNetworkTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientNetworkTests.swift @@ -229,7 +229,7 @@ extension CmxIrohTrustBrokerClientTests { clientNamespace: "legacy", transport: transport ) - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { + await #expect(throws: CmxIrohTrustBrokerClientError.connectivity(nil)) { _ = try await client.discover() } #expect(await transport.requests().isEmpty) @@ -255,7 +255,13 @@ extension CmxIrohTrustBrokerClientTests { ) let client = try makeNetworkClient(transport: transport) - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { + let expected = CmxIrohTrustBrokerClientError.connectivity( + CmxIrohBrokerConnectivityCause( + urlErrorCode: URLError.Code.notConnectedToInternet.rawValue + ) + ) + #expect(String(describing: expected) == "connectivity(notConnectedToInternet(-1009))") + await #expect(throws: expected) { _ = try await client.discover() } } diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/CtlWireModels.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/CtlWireModels.swift new file mode 100644 index 000000000000..d11d037fe0c9 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/CtlWireModels.swift @@ -0,0 +1,869 @@ +// This file was generated from JSON Schema using quicktype, do not modify it directly. +// To parse the JSON, add this file to your project and do: +// +// let cTLACK = try? JSONDecoder().decode(CTLACK.self, from: jsonData) +// let cTLError = try? JSONDecoder().decode(CTLError.self, from: jsonData) +// let cTLDirectory = try? JSONDecoder().decode(CTLDirectory.self, from: jsonData) +// let cTLHelloACK = try? JSONDecoder().decode(CTLHelloACK.self, from: jsonData) +// let cTLHello = try? JSONDecoder().decode(CTLHello.self, from: jsonData) +// let cTLHintUpdate = try? JSONDecoder().decode(CTLHintUpdate.self, from: jsonData) +// let cTLMintRequest = try? JSONDecoder().decode(CTLMintRequest.self, from: jsonData) +// let cTLPublishHint = try? JSONDecoder().decode(CTLPublishHint.self, from: jsonData) +// let cTLRelayPasses = try? JSONDecoder().decode(CTLRelayPasses.self, from: jsonData) +// let cTLSnapshotComplete = try? JSONDecoder().decode(CTLSnapshotComplete.self, from: jsonData) + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +public import Foundation + +// MARK: - CTLACK +public struct CTLACK: Codable, Equatable { + public let payload: CTLACKPayload + /// directory/hint revision the client has applied; stops the server's retry ladder for + /// revisions up to and including it + public let rev: Int + public let type: CTLACKType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case rev = "rev" + case type = "type" + case v = "v" + } + + public init(payload: CTLACKPayload, rev: Int, type: CTLACKType, v: Int) { + self.payload = payload + self.rev = rev + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLACKPayload +public struct CTLACKPayload: Codable, Equatable { + /// optional client stamp of when the acked revision was applied + public let appliedAt: Date? + + public enum CodingKeys: String, CodingKey { + case appliedAt = "appliedAt" + } + + public init(appliedAt: Date?) { + self.appliedAt = appliedAt + } +} + +public enum CTLACKType: String, Codable, Equatable { + case ack = "ack" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLError +public struct CTLError: Codable, Equatable { + public let payload: CTLErrorPayload + public let type: CTLErrorType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case type = "type" + case v = "v" + } + + public init(payload: CTLErrorPayload, type: CTLErrorType, v: Int) { + self.payload = payload + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLErrorPayload +public struct CTLErrorPayload: Codable, Equatable { + public let code: String + public let message: String + public let retryable: Bool + + public enum CodingKeys: String, CodingKey { + case code = "code" + case message = "message" + case retryable = "retryable" + } + + public init(code: String, message: String, retryable: Bool) { + self.code = code + self.message = message + self.retryable = retryable + } +} + +public enum CTLErrorType: String, Codable, Equatable { + case error = "error" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLDirectory +public struct CTLDirectory: Codable, Equatable { + public let payload: CTLDirectoryPayload + /// monotonic account route revision this fact reflects + public let rev: Int + public let type: CTLDirectoryType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case rev = "rev" + case type = "type" + case v = "v" + } + + public init(payload: CTLDirectoryPayload, rev: Int, type: CTLDirectoryType, v: Int) { + self.payload = payload + self.rev = rev + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLDirectoryPayload +public struct CTLDirectoryPayload: Codable, Equatable { + public let bindings: [Binding] + public let grantVerificationKeys: [GrantVerificationKey] + /// server stamp when this directory was issued; anchor of the trust lease + public let issuedAt: Date + /// per-platform app-version floors; clients below the floor must update before participating + public let minimumSupportedVersion: PurpleMinimumSupportedVersion? + public let relayFleet: [String] + public let routeContractVersion: Int + /// trust lease duration; clients treat the directory as stale once issuedAt + ttlSeconds + /// passes without a re-stamp + public let ttlSeconds: Int + + public enum CodingKeys: String, CodingKey { + case bindings = "bindings" + case grantVerificationKeys = "grantVerificationKeys" + case issuedAt = "issuedAt" + case minimumSupportedVersion = "minimumSupportedVersion" + case relayFleet = "relayFleet" + case routeContractVersion = "routeContractVersion" + case ttlSeconds = "ttlSeconds" + } + + public init(bindings: [Binding], grantVerificationKeys: [GrantVerificationKey], issuedAt: Date, minimumSupportedVersion: PurpleMinimumSupportedVersion?, relayFleet: [String], routeContractVersion: Int, ttlSeconds: Int) { + self.bindings = bindings + self.grantVerificationKeys = grantVerificationKeys + self.issuedAt = issuedAt + self.minimumSupportedVersion = minimumSupportedVersion + self.relayFleet = relayFleet + self.routeContractVersion = routeContractVersion + self.ttlSeconds = ttlSeconds + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - Binding +public struct Binding: Codable, Equatable { + public let appVersion: String? + public let bindingID: String + public let capabilities: [String]? + public let clientNamespace: String + public let deviceID: String? + public let endpointID: String + public let homeRelayURL: String? + public let instanceTag: String? + /// when this device last confirmed itself over its own control-plane hello + public let lastConfirmedAt: Date? + public let releaseTrack: ReleaseTrack? + /// authorization kill switch, orthogonal to status; peers must deny P2P admission to a + /// revoked device + public let revoked: Bool + /// device lifecycle state from the account overlay; a binding never confirmed by its own + /// hello stays seeded + public let status: Status? + public let updatedAt: Date? + + public enum CodingKeys: String, CodingKey { + case appVersion = "appVersion" + case bindingID = "bindingId" + case capabilities = "capabilities" + case clientNamespace = "clientNamespace" + case deviceID = "deviceId" + case endpointID = "endpointId" + case homeRelayURL = "homeRelayUrl" + case instanceTag = "instanceTag" + case lastConfirmedAt = "lastConfirmedAt" + case releaseTrack = "releaseTrack" + case revoked = "revoked" + case status = "status" + case updatedAt = "updatedAt" + } + + public init(appVersion: String?, bindingID: String, capabilities: [String]?, clientNamespace: String, deviceID: String?, endpointID: String, homeRelayURL: String?, instanceTag: String?, lastConfirmedAt: Date?, releaseTrack: ReleaseTrack?, revoked: Bool, status: Status?, updatedAt: Date?) { + self.appVersion = appVersion + self.bindingID = bindingID + self.capabilities = capabilities + self.clientNamespace = clientNamespace + self.deviceID = deviceID + self.endpointID = endpointID + self.homeRelayURL = homeRelayURL + self.instanceTag = instanceTag + self.lastConfirmedAt = lastConfirmedAt + self.releaseTrack = releaseTrack + self.revoked = revoked + self.status = status + self.updatedAt = updatedAt + } +} + +public enum ReleaseTrack: String, Codable, Equatable { + case appstore = "appstore" + case beta = "beta" + case dev = "dev" + case nightly = "nightly" + case releaseTrackInternal = "internal" + case stable = "stable" +} + +/// device lifecycle state from the account overlay; a binding never confirmed by its own +/// hello stays seeded +public enum Status: String, Codable, Equatable { + case active = "active" + case pending = "pending" + case retired = "retired" + case seeded = "seeded" + case stale = "stale" + case superseded = "superseded" + case suspended = "suspended" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - GrantVerificationKey +public struct GrantVerificationKey: Codable, Equatable { + public let alg: String + public let keyID: String + public let publicKey: String + + public enum CodingKeys: String, CodingKey { + case alg = "alg" + case keyID = "keyId" + case publicKey = "publicKey" + } + + public init(alg: String, keyID: String, publicKey: String) { + self.alg = alg + self.keyID = keyID + self.publicKey = publicKey + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +/// per-platform app-version floors; clients below the floor must update before participating +// MARK: - PurpleMinimumSupportedVersion +public struct PurpleMinimumSupportedVersion: Codable, Equatable { + public let ios: String? + public let mac: String? + + public enum CodingKeys: String, CodingKey { + case ios = "ios" + case mac = "mac" + } + + public init(ios: String?, mac: String?) { + self.ios = ios + self.mac = mac + } +} + +public enum CTLDirectoryType: String, Codable, Equatable { + case directory = "directory" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLHelloACK +public struct CTLHelloACK: Codable, Equatable { + public let payload: CTLHelloACKPayload + public let type: CTLHelloACKType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case type = "type" + case v = "v" + } + + public init(payload: CTLHelloACKPayload, type: CTLHelloACKType, v: Int) { + self.payload = payload + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLHelloACKPayload +public struct CTLHelloACKPayload: Codable, Equatable { + /// echo of the directory's per-platform version floors so clients get them before the + /// directory body + public let minimumSupportedVersion: FluffyMinimumSupportedVersion? + /// rev the server resumed the delta stream from; null means full snapshot follows + public let resumedFromRev: Int? + /// control-plane features this server supports (list overlay, ack tracking, revocation) + public let serverCapabilities: [String]? + public let sessionID: String + + public enum CodingKeys: String, CodingKey { + case minimumSupportedVersion = "minimumSupportedVersion" + case resumedFromRev = "resumedFromRev" + case serverCapabilities = "serverCapabilities" + case sessionID = "sessionId" + } + + public init(minimumSupportedVersion: FluffyMinimumSupportedVersion?, resumedFromRev: Int?, serverCapabilities: [String]?, sessionID: String) { + self.minimumSupportedVersion = minimumSupportedVersion + self.resumedFromRev = resumedFromRev + self.serverCapabilities = serverCapabilities + self.sessionID = sessionID + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +/// echo of the directory's per-platform version floors so clients get them before the +/// directory body +// MARK: - FluffyMinimumSupportedVersion +public struct FluffyMinimumSupportedVersion: Codable, Equatable { + public let ios: String? + public let mac: String? + + public enum CodingKeys: String, CodingKey { + case ios = "ios" + case mac = "mac" + } + + public init(ios: String?, mac: String?) { + self.ios = ios + self.mac = mac + } +} + +public enum CTLHelloACKType: String, Codable, Equatable { + case helloACK = "hello_ack" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLHello +public struct CTLHello: Codable, Equatable { + public let payload: CTLHelloPayload + public let type: CTLHelloType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case type = "type" + case v = "v" + } + + public init(payload: CTLHelloPayload, type: CTLHelloType, v: Int) { + self.payload = payload + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLHelloPayload +public struct CTLHelloPayload: Codable, Equatable { + public let appVersion: String? + public let capabilities: [String]? + /// optional client self-identification; presence of any client-info field confirms the + /// device into the account overlay + public let deviceID: String? + public let endpointID: String + /// highest rev this client has on disk; server streams deltas after it, or a full snapshot + /// when null/too old + public let haveRev: Int? + public let platform: Platform? + public let releaseTrack: ReleaseTrack? + public let wantPasses: Bool + + public enum CodingKeys: String, CodingKey { + case appVersion = "appVersion" + case capabilities = "capabilities" + case deviceID = "deviceId" + case endpointID = "endpointId" + case haveRev = "haveRev" + case platform = "platform" + case releaseTrack = "releaseTrack" + case wantPasses = "wantPasses" + } + + public init(appVersion: String?, capabilities: [String]?, deviceID: String?, endpointID: String, haveRev: Int?, platform: Platform?, releaseTrack: ReleaseTrack?, wantPasses: Bool) { + self.appVersion = appVersion + self.capabilities = capabilities + self.deviceID = deviceID + self.endpointID = endpointID + self.haveRev = haveRev + self.platform = platform + self.releaseTrack = releaseTrack + self.wantPasses = wantPasses + } +} + +public enum Platform: String, Codable, Equatable { + case ios = "ios" + case mac = "mac" +} + +public enum CTLHelloType: String, Codable, Equatable { + case hello = "hello" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLHintUpdate +public struct CTLHintUpdate: Codable, Equatable { + public let payload: CTLHintUpdatePayload + /// monotonic account route revision this fact reflects + public let rev: Int + public let type: CTLHintUpdateType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case rev = "rev" + case type = "type" + case v = "v" + } + + public init(payload: CTLHintUpdatePayload, rev: Int, type: CTLHintUpdateType, v: Int) { + self.payload = payload + self.rev = rev + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLHintUpdatePayload +public struct CTLHintUpdatePayload: Codable, Equatable { + public let endpointID: String + public let homeRelayURL: String + public let updatedAt: Date? + + public enum CodingKeys: String, CodingKey { + case endpointID = "endpointId" + case homeRelayURL = "homeRelayUrl" + case updatedAt = "updatedAt" + } + + public init(endpointID: String, homeRelayURL: String, updatedAt: Date?) { + self.endpointID = endpointID + self.homeRelayURL = homeRelayURL + self.updatedAt = updatedAt + } +} + +public enum CTLHintUpdateType: String, Codable, Equatable { + case hintUpdate = "hint_update" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLMintRequest +public struct CTLMintRequest: Codable, Equatable { + public let payload: CTLMintRequestPayload + public let type: CTLMintRequestType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case type = "type" + case v = "v" + } + + public init(payload: CTLMintRequestPayload, type: CTLMintRequestType, v: Int) { + self.payload = payload + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLMintRequestPayload +public struct CTLMintRequestPayload: Codable, Equatable { + public let endpointID: String + /// Optional signed identity assertion (reserved for the source-of-truth migration; phase A + /// authorizes via the bearer-authenticated socket and confirms hints by re-fetching + /// discovery) + public let proof: PurpleProof? + + public enum CodingKeys: String, CodingKey { + case endpointID = "endpointId" + case proof = "proof" + } + + public init(endpointID: String, proof: PurpleProof?) { + self.endpointID = endpointID + self.proof = proof + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +/// Optional signed identity assertion (reserved for the source-of-truth migration; phase A +/// authorizes via the bearer-authenticated socket and confirms hints by re-fetching +/// discovery) +// MARK: - PurpleProof +public struct PurpleProof: Codable, Equatable { + public let bindingID: String + /// base64 Ed25519 signature by the endpoint key + public let signature: String + /// RFC3339 issue time; server enforces freshness window + public let timestamp: String + + public enum CodingKeys: String, CodingKey { + case bindingID = "bindingId" + case signature = "signature" + case timestamp = "timestamp" + } + + public init(bindingID: String, signature: String, timestamp: String) { + self.bindingID = bindingID + self.signature = signature + self.timestamp = timestamp + } +} + +public enum CTLMintRequestType: String, Codable, Equatable { + case mintRequest = "mint_request" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLPublishHint +public struct CTLPublishHint: Codable, Equatable { + public let payload: CTLPublishHintPayload + public let type: CTLPublishHintType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case type = "type" + case v = "v" + } + + public init(payload: CTLPublishHintPayload, type: CTLPublishHintType, v: Int) { + self.payload = payload + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLPublishHintPayload +public struct CTLPublishHintPayload: Codable, Equatable { + public let endpointID: String + public let homeRelayURL: String + /// Optional signed identity assertion (reserved for the source-of-truth migration; phase A + /// authorizes via the bearer-authenticated socket and confirms hints by re-fetching + /// discovery) + public let proof: FluffyProof? + + public enum CodingKeys: String, CodingKey { + case endpointID = "endpointId" + case homeRelayURL = "homeRelayUrl" + case proof = "proof" + } + + public init(endpointID: String, homeRelayURL: String, proof: FluffyProof?) { + self.endpointID = endpointID + self.homeRelayURL = homeRelayURL + self.proof = proof + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +/// Optional signed identity assertion (reserved for the source-of-truth migration; phase A +/// authorizes via the bearer-authenticated socket and confirms hints by re-fetching +/// discovery) +// MARK: - FluffyProof +public struct FluffyProof: Codable, Equatable { + public let bindingID: String + /// base64 Ed25519 signature by the endpoint key + public let signature: String + /// RFC3339 issue time; server enforces freshness window + public let timestamp: String + + public enum CodingKeys: String, CodingKey { + case bindingID = "bindingId" + case signature = "signature" + case timestamp = "timestamp" + } + + public init(bindingID: String, signature: String, timestamp: String) { + self.bindingID = bindingID + self.signature = signature + self.timestamp = timestamp + } +} + +public enum CTLPublishHintType: String, Codable, Equatable { + case publishHint = "publish_hint" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLRelayPasses +public struct CTLRelayPasses: Codable, Equatable { + public let payload: CTLRelayPassesPayload + /// monotonic account route revision this fact reflects + public let rev: Int + public let type: CTLRelayPassesType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case rev = "rev" + case type = "type" + case v = "v" + } + + public init(payload: CTLRelayPassesPayload, rev: Int, type: CTLRelayPassesType, v: Int) { + self.payload = payload + self.rev = rev + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLRelayPassesPayload +public struct CTLRelayPassesPayload: Codable, Equatable { + public let endpointID: String + public let passes: [Pass] + + public enum CodingKeys: String, CodingKey { + case endpointID = "endpointId" + case passes = "passes" + } + + public init(endpointID: String, passes: [Pass]) { + self.endpointID = endpointID + self.passes = passes + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - Pass +public struct Pass: Codable, Equatable { + public let expiresAt: Date + public let generation: Int + /// server-driven early-refresh point (expiry minus margin) + public let refreshAfter: Date + public let relayURL: String + public let token: String + + public enum CodingKeys: String, CodingKey { + case expiresAt = "expiresAt" + case generation = "generation" + case refreshAfter = "refreshAfter" + case relayURL = "relayUrl" + case token = "token" + } + + public init(expiresAt: Date, generation: Int, refreshAfter: Date, relayURL: String, token: String) { + self.expiresAt = expiresAt + self.generation = generation + self.refreshAfter = refreshAfter + self.relayURL = relayURL + self.token = token + } +} + +public enum CTLRelayPassesType: String, Codable, Equatable { + case relayPasses = "relay_passes" +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLSnapshotComplete +public struct CTLSnapshotComplete: Codable, Equatable { + public let payload: CTLSnapshotCompletePayload + /// monotonic account route revision this fact reflects + public let rev: Int + public let type: CTLSnapshotCompleteType + /// control-plane protocol version, 1 + public let v: Int + + public enum CodingKeys: String, CodingKey { + case payload = "payload" + case rev = "rev" + case type = "type" + case v = "v" + } + + public init(payload: CTLSnapshotCompletePayload, rev: Int, type: CTLSnapshotCompleteType, v: Int) { + self.payload = payload + self.rev = rev + self.type = type + self.v = v + } +} + +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + +// MARK: - CTLSnapshotCompletePayload +public struct CTLSnapshotCompletePayload: Codable, Equatable { + /// server freshness re-stamp; when a hello's haveRev already matches head this frame alone + /// re-arms the directory trust lease without resending the body + public let issuedAt: Date? + + public enum CodingKeys: String, CodingKey { + case issuedAt = "issuedAt" + } + + public init(issuedAt: Date?) { + self.issuedAt = issuedAt + } +} + +public enum CTLSnapshotCompleteType: String, Codable, Equatable { + case snapshotComplete = "snapshot_complete" +} diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxControlPlaneClient.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxControlPlaneClient.swift new file mode 100644 index 000000000000..2c6662144544 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxControlPlaneClient.swift @@ -0,0 +1,562 @@ +public import Foundation + +/// Persisted control-plane sync position: the highest account route revision +/// this device has fully received. Sent as `haveRev` on reconnect so the +/// server can resume with deltas instead of a full snapshot. +struct IrxControlPlaneCursor: Codable, Equatable, Sendable { + var haveRev: Int? +} + +/// The always-on fact channel to the per-account control-plane Durable +/// Object. Never on the dial path: the phone dials from persisted state and +/// this socket delivers corrections (fresh relay passes, home-relay hints, +/// directory changes) the instant they exist, plus the initial snapshot as a +/// burst of revisioned deltas. +/// +/// Lifecycle mirrors the credential autopilot: `start()` owns a reconnect +/// loop with capped jittered backoff, `kick()` is the foreground reset (iOS +/// suspension kills the socket silently; that is expected), `stop()` ends it. +public actor IrxControlPlaneClient { + public struct Configuration: Sendable { + public var socketURL: URL + public var endpointIDHex: String + public var wantPasses: Bool + public var cacheDirectory: URL + /// Optional client identification carried on the hello so the server + /// can seed and version-stamp this device's directory entry. + public var clientInfo: IrxCtlClientInfo? + /// The app namespace (`X-Cmux-App-Namespace`) sent on the socket + /// open. The DO reuses it for upstream discovery, so it must match + /// the namespace the broker service registers under. + public var clientNamespace: String? + + public init( + socketURL: URL, + endpointIDHex: String, + wantPasses: Bool, + cacheDirectory: URL, + clientInfo: IrxCtlClientInfo? = nil, + clientNamespace: String? = nil + ) { + self.socketURL = socketURL + self.endpointIDHex = endpointIDHex + self.wantPasses = wantPasses + self.cacheDirectory = cacheDirectory + self.clientInfo = clientInfo + self.clientNamespace = clientNamespace + } + } + + public struct Handlers: Sendable { + public var onRelayPasses: @Sendable ([IrxRelayCredential]) async -> Bool + public var onHintUpdate: @Sendable (_ endpointIDHex: String, _ relayURL: String) async -> Bool + public var onDirectory: @Sendable (CTLDirectoryPayload) async -> Bool + public var onSnapshotComplete: @Sendable (_ rev: Int) async -> Void + /// The list-auth overlay of a directory fact (rev + lease stamp + + /// per-entry authorization). The consumer applies it, then calls + /// ``IrxControlPlaneClient/acknowledge(rev:)``. + public var onDirectoryFact: (@Sendable (IrxCtlDirectoryFact) async -> Bool)? + /// An explicit freshness re-stamp (a `current` frame, or a + /// `snapshot_complete` carrying `issuedAt`). + public var onFreshness: (@Sendable (_ rev: Int, _ issuedAt: Date) async -> Void)? + + public init( + onRelayPasses: @escaping @Sendable ([IrxRelayCredential]) async -> Bool, + onHintUpdate: @escaping @Sendable (String, String) async -> Bool, + onDirectory: @escaping @Sendable (CTLDirectoryPayload) async -> Bool, + onSnapshotComplete: @escaping @Sendable (Int) async -> Void, + onDirectoryFact: (@Sendable (IrxCtlDirectoryFact) async -> Bool)? = nil, + onFreshness: (@Sendable (_ rev: Int, _ issuedAt: Date) async -> Void)? = nil + ) { + self.onRelayPasses = onRelayPasses + self.onHintUpdate = onHintUpdate + self.onDirectory = onDirectory + self.onSnapshotComplete = onSnapshotComplete + self.onDirectoryFact = onDirectoryFact + self.onFreshness = onFreshness + } + } + + private let configuration: Configuration + /// Stack token pair: the worker's upstream proxy needs BOTH the access + /// token (Authorization) and the refresh token (x-stack-refresh-token); + /// the web API's native auth rejects a bearer alone. + private let tokenPair: @Sendable () async throws -> (access: String, refresh: String)? + private let journal: IrxJournal + private let handlers: Handlers + private let cursorCache: IrxDiskCache + /// JSONDecoder is mutable and therefore stays isolated to this actor. + /// Reusing the instance avoids rebuilding ISO-8601 formatters for every + /// heartbeat or directory frame without introducing shared state. + private let decoder: JSONDecoder + private var loop: Task? + private var socket: URLSessionWebSocketTask? + /// Generation of the one control-plane loop. A cancelled URLSession task + /// may linger inside an awaited receive, so a new kick must invalidate the + /// old loop before starting its replacement. + private var loopGeneration: UInt64 = 0 + private var backoff: Duration = .seconds(1) + /// Highest revision acknowledged on the CURRENT socket; duplicate acks + /// (a directory frame that also fanned hint updates) collapse here. + private var lastAckedRev: Int? + private static let maxBackoff: Duration = .seconds(30) + + /// Frame router probe: read only the discriminator, then decode the + /// exact generated type. Unknown types are journaled and skipped so a + /// newer server can add fact kinds without breaking installed clients + /// (the additive-evolution contract). + private struct TypeProbe: Decodable { + let type: String + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + let iso = ISO8601DateFormatter() + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + decoder.dateDecodingStrategy = .custom { decoder in + let raw = try decoder.singleValueContainer().decode(String.self) + if let date = iso.date(from: raw) ?? fractional.date(from: raw) { + return date + } + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "unparseable date: \(raw)" + )) + } + return decoder + } + + /// The server sends an application heartbeat every 60 seconds. A receive + /// that outlives that interval is a zombie WebSocket, not a healthy idle + /// connection, so bound it and let `run()` own reconnect/backoff. + private static let receiveTimeout: Duration = .seconds(90) + + private struct ReceiveTimeout: Error, Sendable {} + + private func receive( + from task: URLSessionWebSocketTask + ) async throws -> URLSessionWebSocketTask.Message { + do { + return try await withThrowingTaskGroup( + of: URLSessionWebSocketTask.Message.self + ) { group in + group.addTask { try await task.receive() } + group.addTask { + try await Task.sleep(for: Self.receiveTimeout) + throw ReceiveTimeout() + } + defer { group.cancelAll() } + return try await group.next()! + } + } catch is ReceiveTimeout { + task.cancel(with: .goingAway, reason: nil) + journal.record("control-plane", "receive-timeout") + throw IrxConnectionError.closed(nil) + } + } + + /// Reconstruct the generated legacy payload from the tolerant overlay. + /// Older servers omit the lease fields, so `CTLDirectory` cannot decode + /// them directly. Keeping this bridge means the existing directory + /// consumer still receives every route and binding while the overlay + /// consumer applies list-auth defaults independently. + static func legacyDirectoryPayload( + from fact: IrxCtlDirectoryFact, + receivedAt: Date = Date() + ) -> CTLDirectoryPayload { + CTLDirectoryPayload( + bindings: fact.payload.bindings.map { entry in + Binding( + appVersion: entry.appVersion, + bindingID: entry.bindingID ?? "", + capabilities: entry.capabilities, + clientNamespace: entry.clientNamespace ?? "", + deviceID: entry.deviceID, + endpointID: entry.endpointID, + homeRelayURL: entry.homeRelayURL, + instanceTag: entry.instanceTag, + lastConfirmedAt: entry.lastConfirmedAt, + releaseTrack: entry.releaseTrack.flatMap(ReleaseTrack.init(rawValue:)), + revoked: entry.revoked ?? false, + status: entry.status.flatMap(Status.init(rawValue:)), + updatedAt: entry.updatedAt + ) + }, + grantVerificationKeys: fact.payload.grantVerificationKeys ?? [], + issuedAt: fact.payload.issuedAt ?? receivedAt, + minimumSupportedVersion: fact.payload.minimumSupportedVersion.map { + PurpleMinimumSupportedVersion(ios: $0.ios, mac: $0.mac) + }, + relayFleet: fact.payload.relayFleet ?? [], + routeContractVersion: fact.payload.routeContractVersion ?? 1, + ttlSeconds: fact.payload.ttlSeconds ?? IrxDeviceListSnapshot.defaultTTLSeconds + ) + } + + public init( + configuration: Configuration, + tokenPair: @escaping @Sendable () async throws -> (access: String, refresh: String)?, + handlers: Handlers, + journal: IrxJournal + ) { + self.configuration = configuration + self.tokenPair = tokenPair + self.handlers = handlers + self.journal = journal + decoder = Self.makeDecoder() + cursorCache = IrxDiskCache( + fileURL: configuration.cacheDirectory + .appendingPathComponent("control-plane-cursor.json") + ) + } + + // MARK: - Lifecycle + + public func start() { + guard loop == nil else { return } + loopGeneration &+= 1 + let generation = loopGeneration + loop = Task { await self.run(generation: generation) } + journal.record("control-plane", "started") + } + + public func stop() { + loopGeneration &+= 1 + loop?.cancel() + loop = nil + socket?.cancel(with: .goingAway, reason: nil) + socket = nil + journal.record("control-plane", "stopped") + } + + /// Foreground reset: reconnect NOW with a fresh token instead of waiting + /// out whatever backoff a background suspension left behind. + public func kick() { + loopGeneration &+= 1 + let generation = loopGeneration + loop?.cancel() + socket?.cancel(with: .goingAway, reason: nil) + socket = nil + backoff = .seconds(1) + loop = Task { await self.run(generation: generation) } + journal.record("control-plane", "kicked") + } + + // MARK: - Connection loop + + private func run(generation: UInt64) async { + while !Task.isCancelled && generation == loopGeneration { + do { + try await connectAndServe(generation: generation) + } catch is CancellationError { + return + } catch { + journal.record( + "control-plane", "socket-ended", + ["error": String(describing: error)] + ) + } + if Task.isCancelled || generation != loopGeneration { return } + let jitter = Duration.milliseconds(Int.random(in: 0...500)) + let delay = backoff + jitter + backoff = min(backoff * 2, Self.maxBackoff) + journal.record( + "control-plane", "reconnect-scheduled", + ["delay": String(describing: delay)] + ) + try? await Task.sleep(for: delay) + } + } + + private func connectAndServe(generation: UInt64) async throws { + guard let tokens = try await tokenPair() else { + throw IrxConnectionError.closed(nil) + } + guard generation == loopGeneration, !Task.isCancelled else { + throw CancellationError() + } + var request = URLRequest(url: configuration.socketURL) + request.setValue("Bearer \(tokens.access)", forHTTPHeaderField: "Authorization") + request.setValue(tokens.refresh, forHTTPHeaderField: "x-stack-refresh-token") + // The DO borrows this connection's identity for its upstream + // discovery fetches; without the namespace the broker serves the + // release-scoped view and per-tag isolation hides every dev-tagged + // binding — including this device's own peers — from the directory. + if let namespace = configuration.clientNamespace { + request.setValue(namespace, forHTTPHeaderField: "X-Cmux-App-Namespace") + } + let task = URLSession.shared.webSocketTask(with: request) + socket = task + lastAckedRev = nil + task.resume() + + // Hello v2: the generated hello fields plus optional client info + // (device, platform, version, capabilities); old servers ignore the + // extra keys. + let hello = IrxCtlHelloV2( + endpointID: configuration.endpointIDHex, + haveRev: cursorCache.load()?.haveRev, + wantPasses: configuration.wantPasses, + clientInfo: configuration.clientInfo + ) + let helloData = try JSONEncoder().encode(hello) + try await task.send(.string(String(decoding: helloData, as: UTF8.self))) + journal.record( + "control-plane", "hello-sent", + ["have_rev": cursorCache.load()?.haveRev.map(String.init) ?? "-"] + ) + + while !Task.isCancelled && generation == loopGeneration { + let message = try await receive(from: task) + guard generation == loopGeneration else { return } + let data: Data + switch message { + case .string(let text): data = Data(text.utf8) + case .data(let raw): data = raw + @unknown default: continue + } + await route(data, generation: generation) + } + } + + private func route(_ data: Data, generation: UInt64) async { + guard generation == loopGeneration, !Task.isCancelled else { return } + guard let probe = try? decoder.decode(TypeProbe.self, from: data) else { + journal.record("control-plane", "frame-unparseable") + return + } + do { + switch probe.type { + case "ping": + // Cloudflare Workers' hibernation API does not expose a + // portable server-side RFC6455 ping, so the DO uses a small + // application heartbeat. Reply immediately without routing + // it through the durable fact decoder. + guard let socket else { return } + let pong = Data("{\"v\":1,\"type\":\"pong\",\"payload\":{}}".utf8) + do { + try await socket.send( + .string(String(decoding: pong, as: UTF8.self))) + journal.record("control-plane", "pong-sent") + } catch { + journal.record( + "control-plane", "pong-failed", + ["error": String(describing: error)] + ) + } + case "pong": + journal.record("control-plane", "pong-received") + case "hello_ack": + let ack = try decoder.decode(CTLHelloACK.self, from: data) + // List-auth additions (serverCapabilities, minimum version) + // are advisory; tolerate their absence and journal presence. + let overlay = try? decoder.decode(IrxCtlHelloAckOverlay.self, from: data) + journal.record( + "control-plane", "hello-ack", + [ + "session": ack.payload.sessionID, + "resumed_from": ack.payload.resumedFromRev.map(String.init) ?? "snapshot", + "server_capabilities": overlay?.payload?.serverCapabilities? + .joined(separator: ",") ?? "-", + ] + ) + case "relay_passes": + let fact = try decoder.decode(CTLRelayPasses.self, from: data) + guard fact.payload.endpointID == configuration.endpointIDHex else { + journal.record("control-plane", "passes-wrong-endpoint") + return + } + let credentials = fact.payload.passes.map { + IrxRelayCredential( + relayURL: $0.relayURL, + token: $0.token, + expiresAt: $0.expiresAt, + refreshAfter: $0.refreshAfter + ) + } + journal.record( + "control-plane", "passes-received", + ["rev": String(fact.rev), "count": String(credentials.count)] + ) + if await handlers.onRelayPasses(credentials) { + // Relay credentials are a revisioned control-plane fact. + // Ack only after the consumer accepted and persisted them. + await acknowledge(rev: fact.rev) + } + case "hint_update": + let fact = try decoder.decode(CTLHintUpdate.self, from: data) + journal.record( + "control-plane", "hint-update", + [ + "rev": String(fact.rev), + "endpoint": String(fact.payload.endpointID.prefix(12)), + "relay": fact.payload.homeRelayURL, + ] + ) + if await handlers.onHintUpdate( + fact.payload.endpointID, fact.payload.homeRelayURL) + { + // Hint updates are independently revisioned when delivered + // outside a directory snapshot; ack after applying them. + await acknowledge(rev: fact.rev) + } + case "directory": + // The tolerant overlay is the PRIMARY decode: every list-auth + // field is optional there, so directories from both old and + // new servers parse. The generated strict type (which now + // REQUIRES the lease stamp) feeds the legacy handler + // best-effort only. + let listFact = try decoder.decode(IrxCtlDirectoryFact.self, from: data) + journal.record( + "control-plane", "directory", + [ + "rev": String(listFact.rev), + "bindings": String(listFact.payload.bindings.count), + "stamped": String(listFact.payload.issuedAt != nil), + ] + ) + var applied = false + if let fact = try? decoder.decode(CTLDirectory.self, from: data) { + applied = await handlers.onDirectory(fact.payload) + } else { + applied = await handlers.onDirectory( + Self.legacyDirectoryPayload(from: listFact)) + } + for binding in listFact.payload.bindings { + if let relay = binding.homeRelayURL { + applied = await handlers.onHintUpdate(binding.endpointID, relay) && applied + } + } + if let onDirectoryFact = handlers.onDirectoryFact { + applied = await onDirectoryFact(listFact) && applied + } + // Directory delivery is itself a revisioned fact. Ack only + // after every consumer reports durable application. + if applied { await acknowledge(rev: listFact.rev) } + case "current": + // Explicit freshness re-stamp for the device-list lease. + let stamp = try decoder.decode(IrxCtlFreshnessStamp.self, from: data) + journal.record( + "control-plane", "current", + [ + "rev": String(stamp.rev), + "stamped": String(stamp.issuedAt != nil), + ] + ) + if let issuedAt = stamp.issuedAt { + await handlers.onFreshness?(stamp.rev, issuedAt) + } + case "snapshot_complete": + let fact = try decoder.decode(CTLSnapshotComplete.self, from: data) + cursorCache.save(IrxControlPlaneCursor(haveRev: fact.rev)) + backoff = .seconds(1) + journal.record( + "control-plane", "snapshot-complete", ["rev": String(fact.rev)] + ) + await handlers.onSnapshotComplete(fact.rev) + // The server may extend snapshot_complete with issuedAt as a + // lease re-stamp; handle it defensively alongside `current`. + if let stamp = try? decoder.decode( + IrxCtlFreshnessStamp.self, from: data), + let issuedAt = stamp.issuedAt + { + await handlers.onFreshness?(stamp.rev, issuedAt) + } + case "error": + let fact = try decoder.decode(CTLError.self, from: data) + journal.record( + "control-plane", "server-error", + [ + "code": fact.payload.code, + "retryable": String(fact.payload.retryable), + ] + ) + default: + journal.record( + "control-plane", "frame-ignored", ["type": probe.type] + ) + } + } catch { + journal.record( + "control-plane", "frame-decode-failed", + ["type": probe.type, "error": String(describing: error)] + ) + } + } + + // MARK: - Acknowledgment + + /// Confirms a directory/hint revision was APPLIED (persisted and swapped + /// into the live judge), so the server can track fleet convergence. + /// Idempotent per socket: a revision already acknowledged on this + /// connection is skipped; the counter resets on reconnect because the + /// server re-learns position from the hello's `haveRev`. + public func acknowledge(rev: Int) async { + guard let socket else { return } + if let lastAckedRev, rev <= lastAckedRev { return } + guard let data = try? Self.encodedAck(rev: rev) else { return } + do { + try await socket.send(.string(String(decoding: data, as: UTF8.self))) + lastAckedRev = rev + journal.record("control-plane", "acked", ["rev": String(rev)]) + } catch { + journal.record( + "control-plane", "ack-failed", + ["rev": String(rev), "error": String(describing: error)] + ) + } + } + + /// The ack frame bytes (generated `CTLACK`, RFC3339 applied stamp), + /// exposed for wire-shape tests. + static func encodedAck(rev: Int, appliedAt: Date = Date()) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode( + CTLACK(payload: CTLACKPayload(appliedAt: appliedAt), rev: rev, type: .ack, v: 1)) + } + + // MARK: - Publishing (Mac) + + /// Announces this endpoint's home relay to the account's other devices. + /// Purely the instant-propagation lane: the signed HTTPS registration + /// remains the authoritative write, and the server confirms by re-fetching + /// discovery. Best-effort by design; a miss costs nothing (the alarm + /// re-fetch covers it). + public func publishHint(homeRelayURL: String) async { + guard let socket else { return } + let frame = CTLPublishHint( + payload: CTLPublishHintPayload( + endpointID: configuration.endpointIDHex, + homeRelayURL: homeRelayURL, + proof: nil + ), + type: .publishHint, + v: 1 + ) + guard let data = try? JSONEncoder().encode(frame) else { return } + try? await socket.send(.string(String(decoding: data, as: UTF8.self))) + journal.record( + "control-plane", "hint-published", ["relay": homeRelayURL] + ) + } + + /// Requests a socket-delivered mint (server proxies with warm upstream + /// connections and one retry). The reply arrives as an ordinary + /// relay_passes fact; the HTTPS autopilot stays as the fallback minter. + public func requestMint() async { + guard let socket else { return } + let frame = CTLMintRequest( + payload: CTLMintRequestPayload( + endpointID: configuration.endpointIDHex, + proof: nil + ), + type: .mintRequest, + v: 1 + ) + guard let data = try? JSONEncoder().encode(frame) else { return } + try? await socket.send(.string(String(decoding: data, as: UTF8.self))) + journal.record("control-plane", "mint-requested") + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxCtlListAuthOverlays.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxCtlListAuthOverlays.swift new file mode 100644 index 000000000000..f0dda336f6c2 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxCtlListAuthOverlays.swift @@ -0,0 +1,189 @@ +public import Foundation + +// Generated wire values contain only immutable value types; make that fact +// explicit so the actor's handler closures can receive decoded directory +// payloads without changing the schema-generated source. +extension Binding: @unchecked Sendable {} +extension CTLDirectory: @unchecked Sendable {} +extension CTLDirectoryPayload: @unchecked Sendable {} +extension GrantVerificationKey: @unchecked Sendable {} +extension PurpleMinimumSupportedVersion: @unchecked Sendable {} +extension ReleaseTrack: @unchecked Sendable {} +extension Status: @unchecked Sendable {} + +// Hand-written OVERLAY models for the list-auth control-plane additions. +// +// The generated `CtlWireModels.swift` is owned by the schema pipeline and did +// not yet carry these fields when this file was written. These overlays +// decode the SAME frames tolerantly (every list-auth field optional, unknown +// keys ignored) so the client works against both old and new servers. +// RECONCILE: once the generated models gain issuedAt/ttlSeconds/status/ +// revoked/clientInfo/ack, fold these onto the generated types and delete the +// duplicates here. + +/// Directory fact overlay: the generated payload plus the list-auth lease +/// stamp and per-entry authorization state. +public struct IrxCtlDirectoryFact: Decodable, Equatable, Sendable { + public struct Payload: Decodable, Equatable, Sendable { + public var bindings: [Entry] + /// Legacy directory fields retained so the compatibility handler can + /// receive the complete pre-list-auth payload when strict decoding + /// fails on omitted lease fields. + public var grantVerificationKeys: [GrantVerificationKey]? + public var relayFleet: [String]? + public var routeContractVersion: Int? + /// RFC3339 server stamp; absent against a pre-list-auth server. + public var issuedAt: Date? + public var ttlSeconds: Int? + public var minimumSupportedVersion: IrxCtlMinimumSupportedVersion? + } + + public struct Entry: Decodable, Equatable, Sendable { + public var endpointID: String + public var clientNamespace: String? + public var deviceID: String? + public var bindingID: String? + public var instanceTag: String? + public var homeRelayURL: String? + public var status: String? + public var revoked: Bool? + public var appVersion: String? + public var releaseTrack: String? + public var capabilities: [String]? + public var lastConfirmedAt: Date? + public var updatedAt: Date? + public var identityGeneration: Int? + + enum CodingKeys: String, CodingKey { + case endpointID = "endpointId" + case clientNamespace = "clientNamespace" + case deviceID = "deviceId" + case bindingID = "bindingId" + case instanceTag = "instanceTag" + case homeRelayURL = "homeRelayUrl" + case status = "status" + case revoked = "revoked" + case appVersion = "appVersion" + case releaseTrack = "releaseTrack" + case capabilities = "capabilities" + case lastConfirmedAt = "lastConfirmedAt" + case updatedAt = "updatedAt" + case identityGeneration = "identityGeneration" + } + } + + public var rev: Int + public var payload: Payload +} + +/// Optional per-platform floor the server may attach to a directory or a +/// hello-ack. Advisory for now; surfaced in journals only. +public struct IrxCtlMinimumSupportedVersion: Decodable, Equatable, Sendable { + public var mac: String? + public var ios: String? +} + +/// Explicit freshness re-stamp. The server may send a dedicated `current` +/// frame, or extend `snapshot_complete` with `issuedAt`; both are decoded +/// through this shape (stamp accepted at the payload or the top level). +public struct IrxCtlFreshnessStamp: Decodable, Equatable, Sendable { + private struct Payload: Decodable, Equatable, Sendable { + var issuedAt: Date? + } + + public var rev: Int + public private(set) var issuedAt: Date? + + enum CodingKeys: String, CodingKey { + case rev, issuedAt, payload + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + rev = try container.decode(Int.self, forKey: .rev) + let topLevel = try container.decodeIfPresent(Date.self, forKey: .issuedAt) + let payload = try container.decodeIfPresent(Payload.self, forKey: .payload) + issuedAt = topLevel ?? payload?.issuedAt + } +} + +/// Optional client identification attached to the control-plane hello so the +/// server can seed directory entries with platform/version/capabilities. +public struct IrxCtlClientInfo: Equatable, Sendable { + public var deviceID: String + /// "mac" | "ios" + public var platform: String + public var appVersion: String + public var releaseTrack: String + public var capabilities: [String] + + public init( + deviceID: String, + platform: String, + appVersion: String, + releaseTrack: String, + capabilities: [String] + ) { + self.deviceID = deviceID + self.platform = platform + self.appVersion = appVersion + self.releaseTrack = releaseTrack + self.capabilities = capabilities + } + + /// `"+"`, the one-string + /// wire form of version + build. + public static func appVersionString(infoDictionary: [String: Any]?) -> String { + let short = infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + let build = infoDictionary?["CFBundleVersion"] as? String ?? "0" + return "\(short)+\(build)" + } +} + +/// Hello v2 wire frame: the generated hello fields plus optional client info. +/// Old servers ignore the extra keys. +struct IrxCtlHelloV2: Encodable { + struct Payload: Encodable { + var endpointId: String + var haveRev: Int? + var wantPasses: Bool + var deviceId: String? + var platform: String? + var appVersion: String? + var releaseTrack: String? + var capabilities: [String]? + } + + var v = 1 + var type = "hello" + var payload: Payload + + init( + endpointID: String, + haveRev: Int?, + wantPasses: Bool, + clientInfo: IrxCtlClientInfo? + ) { + payload = Payload( + endpointId: endpointID, + haveRev: haveRev, + wantPasses: wantPasses, + deviceId: clientInfo?.deviceID, + platform: clientInfo?.platform, + appVersion: clientInfo?.appVersion, + releaseTrack: clientInfo?.releaseTrack, + capabilities: clientInfo?.capabilities + ) + } +} + +/// Hello-ack overlay for the list-auth additions; every field optional so an +/// old server's ack still decodes. +struct IrxCtlHelloAckOverlay: Decodable { + struct Payload: Decodable { + var serverCapabilities: [String]? + var minimumSupportedVersion: IrxCtlMinimumSupportedVersion? + } + + var payload: Payload? +} diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxAdmission.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxAdmission.swift index 54851617adb5..4a5e9323ce70 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxAdmission.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxAdmission.swift @@ -33,25 +33,27 @@ public struct IrxAdmissionDenied: Error, Equatable, Sendable { } } -/// Grant judgment seam: given the presented grant JWS and the TLS-proved -/// remote key, either return the admitted peer tuple or throw -/// ``IrxAdmissionDenied``. Implemented app-side with the broker's pinned -/// verification keys; deliberately OFFLINE (no backend call sits on the -/// admission path - revocations enforce at the next admission). +/// Admission judgment seam: given the (optional) presented grant JWS and the +/// TLS-proved remote key, either return the admitted peer tuple or throw +/// ``IrxAdmissionDenied``. The list judge ignores the grant entirely; the +/// legacy grant judge requires one. Deliberately OFFLINE (no backend call +/// sits on the admission path - revocations enforce via the pushed device +/// list, or at the next admission). public typealias IrxGrantJudgment = - @Sendable (_ grantJWS: String, _ remoteEndpointIDHex: String) throws -> IrxAdmittedPeerInfo + @Sendable (_ grantJWS: String?, _ remoteEndpointIDHex: String) throws -> IrxAdmittedPeerInfo public enum IrxAdmission { /// Admission must resolve fast or fail loud; nothing here touches the /// network beyond the connection itself. public static let deadline: Duration = .seconds(5) - /// Client half: open the control lane, present the grant, await the - /// admit. A denial arrives as the connection's own termination and is - /// rethrown with its parsed code. + /// Client half: open the control lane, send the hello (grantless in + /// list-auth mode; the optional grant exists only for legacy dialects), + /// await the admit. A denial arrives as the connection's own termination + /// and is rethrown with its parsed code. public static func performClient( connection: IrxConnection, - grantJWS: String, + grantJWS: String? = nil, journal: IrxJournal ) async throws -> (IrxAdmit, IrxLaneStream) { let startedAt = DispatchTime.now() diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift index 8cfaf157a99e..1b512d87c139 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift @@ -90,6 +90,16 @@ public actor IrxBrokerService { /// Rotates only when the endpoint identity rotates (legacy-adopted /// identities carry their existing generation). public var identityGeneration: Int + /// The signed-in account. With it, Release builds keep the broker + /// caches in the Keychain (`com.cmuxterm.irx.cache.v1`, account + /// `||`). Scoped Release caches never + /// import unscoped legacy JSON files because those snapshots have no + /// account/backend owner; a fresh authenticated registration or mint + /// repopulates the scoped item. Without a scope (and in every DEBUG + /// build), the files remain the temporary store. + public var accountID: String? + /// The app's Keychain access group (iOS); nil on macOS. + public var keychainAccessGroup: String? public init( baseURL: URL, @@ -98,7 +108,9 @@ public actor IrxBrokerService { platform: CmxIrohPlatform, displayName: String?, cacheDirectory: URL, - identityGeneration: Int = 1 + identityGeneration: Int = 1, + accountID: String? = nil, + keychainAccessGroup: String? = nil ) { self.baseURL = baseURL self.clientNamespace = clientNamespace @@ -107,6 +119,17 @@ public actor IrxBrokerService { self.displayName = displayName self.cacheDirectory = cacheDirectory self.identityGeneration = identityGeneration + self.accountID = accountID + self.keychainAccessGroup = keychainAccessGroup + } + + var cacheScope: IrxBrokerCacheScope? { + guard let accountID, let backendHost = baseURL.host else { return nil } + return IrxBrokerCacheScope( + accountID: accountID, + backendHost: backendHost, + keychainAccessGroup: keychainAccessGroup + ) } } @@ -114,10 +137,10 @@ public actor IrxBrokerService { private let identity: IrxIdentity private let journal: IrxJournal private let client: CmxIrohTrustBrokerClient - private let bindingCache: IrxDiskCache - private let trustCache: IrxDiskCache - private let credentialCache: IrxDiskCache - private let grantCache: IrxDiskCache<[String: IrxGrantSnapshot]> + private let bindingCache: any IrxJSONCache + private let trustCache: any IrxJSONCache + private let credentialCache: any IrxJSONCache + private let grantCache: any IrxJSONCache<[String: IrxGrantSnapshot]> private var registrationInFlight: Task? private var lastHintRegistered: (url: String?, at: Date)? private var lastDiscovery: CmxIrohDiscoveryResponse? @@ -140,7 +163,12 @@ public actor IrxBrokerService { ) }) let dir = configuration.cacheDirectory - bindingCache = IrxDiskCache(fileURL: dir.appendingPathComponent("binding.json")) + let scope = configuration.cacheScope + bindingCache = IrxBrokerCacheFactory.make( + kind: "binding", + fileURL: dir.appendingPathComponent("binding.json"), + scope: scope + ) // Warm launches skip register() for speed, but register() is what // arms per-request binding-proof signing; an unarmed client sends // proofless mints that the broker 403s (binding_request_proof_required) @@ -168,9 +196,21 @@ public actor IrxBrokerService { clientNamespace: configuration.clientNamespace, bindingAuthorization: retainedAuthorization ) - trustCache = IrxDiskCache(fileURL: dir.appendingPathComponent("trust.json")) - credentialCache = IrxDiskCache(fileURL: dir.appendingPathComponent("relay-credentials.json")) - grantCache = IrxDiskCache(fileURL: dir.appendingPathComponent("grants.json")) + trustCache = IrxBrokerCacheFactory.make( + kind: "trust", + fileURL: dir.appendingPathComponent("trust.json"), + scope: scope + ) + credentialCache = IrxBrokerCacheFactory.make( + kind: "relay-credentials", + fileURL: dir.appendingPathComponent("relay-credentials.json"), + scope: scope + ) + grantCache = IrxBrokerCacheFactory.make( + kind: "grants", + fileURL: dir.appendingPathComponent("grants.json"), + scope: scope + ) } /// The underlying trust-broker client, exposed for the legacy-dialect @@ -299,6 +339,13 @@ public actor IrxBrokerService { trustCache.load() } + /// Synchronous trust read for the admission path (no actor hop). Reads + /// the SAME cache the discovery write path uses, so the Release keychain + /// migration can never strand admission on a deleted legacy file. + public nonisolated func cachedTrustForAdmission() -> IrxTrustSnapshot? { + trustCache.load() + } + /// Fresh-enough discovery, from memory or the wire. Never called on the /// admission path; admission uses `cachedTrust()`. public func discover(maximumAge: TimeInterval = 30) async throws -> CmxIrohDiscoveryResponse { @@ -376,9 +423,15 @@ public actor IrxBrokerService { public func mintRelayCredentials() async throws -> [IrxRelayCredential] { let startedAt = DispatchTime.now() let endpointID = try CmxIrohPeerIdentity(endpointID: identity.endpointIDHex) + // Union of both mint hardenings: the stale-pooled-connection retry + // (first POST after idle dies on a dead keep-alive socket) wraps the + // call, and a proof rejection still invalidates the cached binding so + // the next attempt re-registers instead of looping unsigned. let bootstrap: CmxIrohRelayBootstrapResponse do { - bootstrap = try await client.issueRelayBootstrap(endpointID: endpointID) + bootstrap = try await issueRelayBootstrapRetryingStaleConnection( + endpointID: endpointID + ) } catch { invalidateBindingOnProofRejection(error) throw error @@ -436,6 +489,99 @@ public actor IrxBrokerService { return minted } + /// Accepts control-plane-pushed relay credentials under the SAME rules as + /// a mint: every relay must be in the authenticated fleet allowlist, the + /// snapshot is identity-bound, and freshness is monotonic (pushed passes + /// that don't outlive the cached set are dropped, so a delayed push can + /// never regress local state). Returns the accepted set, or nil. + public func acceptPushedRelayCredentials( + _ pushed: [IrxRelayCredential] + ) -> [IrxRelayCredential]? { + guard !pushed.isEmpty else { return nil } + let allowedFleet = Set(trustCache.load()?.relayFleet ?? []) + guard allowedFleet.isEmpty == false, + pushed.allSatisfy({ allowedFleet.contains($0.relayURL) }) + else { + journal.record( + "broker", "pushed-credentials-rejected", + ["reason": "fleet-allowlist"] + ) + return nil + } + let cachedSnapshot = credentialCache.load().flatMap { snapshot in + snapshot.endpointIDHex == identity.endpointIDHex ? snapshot : nil + } + var mergedByRelay = Dictionary( + uniqueKeysWithValues: (cachedSnapshot?.credentials ?? []).map { + ($0.relayURL, $0) + }) + var accepted: [IrxRelayCredential] = [] + let now = Date() + for credential in pushed { + guard credential.expiresAt > now, + credential.refreshAfter < credential.expiresAt + else { continue } + guard + mergedByRelay[credential.relayURL].map({ + credential.expiresAt > $0.expiresAt + }) ?? true + else { continue } + mergedByRelay[credential.relayURL] = credential + accepted.append(credential) + } + guard !accepted.isEmpty else { + journal.record( + "broker", "pushed-credentials-rejected", ["reason": "stale"] + ) + return nil + } + let pushedMax = accepted.map(\.expiresAt).max() ?? .distantPast + credentialCache.save( + IrxRelayCredentialSnapshot( + credentials: mergedByRelay.values.sorted { $0.relayURL < $1.relayURL }, + mintedAt: Date(), + endpointIDHex: identity.endpointIDHex + )) + journal.record( + "broker", "relay-passes-pushed", + [ + "relays": accepted.map(\.relayURL).joined(separator: ","), + "expires_at": ISO8601DateFormatter().string(from: pushedMax), + ] + ) + return accepted + } + + /// One immediate retry for the connection-reuse failure class. + /// + /// The autopilot mints every ~3-4 minutes, longer than the broker edge's + /// idle keep-alive window, so the first POST of a cycle deterministically + /// lands on a pooled connection the server already closed: the write + /// succeeds into the dead socket, the first read fails with ECONNRESET, + /// and URLSession surfaces NSURLErrorNetworkConnectionLost (-1005) + /// without a transparent retry because the POST body was already written + /// (Apple QA1941). That failure also purged the dead pooled connection, + /// so one immediate retry runs on a fresh connection. Minting is + /// idempotent, the retry is bounded to exactly one attempt for exactly + /// this failure class, and the autopilot's half-remaining-validity sleep + /// loop stays the outer safety net for everything else. + private func issueRelayBootstrapRetryingStaleConnection( + endpointID: CmxIrohPeerIdentity + ) async throws -> CmxIrohRelayBootstrapResponse { + do { + return try await client.issueRelayBootstrap(endpointID: endpointID) + } catch let error as CmxIrohTrustBrokerClientError { + guard case let .connectivity(cause?) = error, + cause.isConnectionReuseFailure + else { throw error } + journal.record( + "broker", "relay-mint-retried", + ["error": String(describing: error)] + ) + return try await client.issueRelayBootstrap(endpointID: endpointID) + } + } + // MARK: - Pair grants (keyed by the acceptor's endpoint, what routes carry) public func cachedGrant( diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift index c17a80b6ffe3..c7d5e60a020e 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift @@ -138,6 +138,10 @@ public actor IrxConnection { nonisolated public let role: Role nonisolated public let remoteEndpointIDHex: String private let connection: Connection + /// Instant of the most recent keepalive pong; nil before the first pong. + /// Foreground staleness checks read this to decide zombie-vs-live after + /// a suspension (a QUIC connection can be long dead without isClosed). + public private(set) var lastPongAt: ContinuousClock.Instant? private let journal: IrxJournal private var closedFlag = false private var localTermination: IrxTermination? @@ -233,8 +237,10 @@ public actor IrxConnection { /// Continuous client-side keepalive on a dedicated lane: one tiny ping /// every interval, pong deadline enforced per ping, every exchange /// journaled with RTT and the selected path (the soak's relay-attribution - /// evidence). A miss closes the connection with `keepalive-timeout` and - /// reports death so the engine redials immediately. + /// evidence). A single miss re-pings immediately (journaled as a `miss`, + /// not a death: one transient stall must never sever a healthy session); + /// `IrxProtocol.keepaliveStrikeLimit` consecutive misses close with + /// `keepalive-timeout` and report death so the engine redials at once. public func startClientKeepalive( interval: Duration = IrxProtocol.keepaliveInterval, deadline: Duration = IrxProtocol.keepaliveDeadline, @@ -243,8 +249,11 @@ public actor IrxConnection { guard keepaliveTask == nil else { return } let lane = try await openLane(IrxLaneDescriptor(lane: .keepalive)) keepaliveTask = Task { [journal] in + var strikes = 0 while !Task.isCancelled { - try? await Task.sleep(for: interval) + if strikes == 0 { + try? await Task.sleep(for: interval) + } guard !Task.isCancelled else { return } let seq = await self.nextPingSeq() let sentAt = DispatchTime.now() @@ -274,8 +283,22 @@ public actor IrxConnection { "path": self.selectedPathDescription(), ] ) + await self.notePong() + strikes = 0 } catch { guard !Task.isCancelled else { return } + strikes += 1 + if strikes < IrxProtocol.keepaliveStrikeLimit { + journal.record( + "keepalive", "miss", + [ + "seq": String(seq), + "strike": String(strikes), + "path": self.selectedPathDescription(), + ] + ) + continue + } journal.record( "keepalive", "timeout", ["seq": String(seq), "path": self.selectedPathDescription()] @@ -288,6 +311,30 @@ public actor IrxConnection { } } + /// Authorizes NAT traversal for this connection (automatic path mode + /// only): iroh then exchanges direct candidates over the relay side + /// channel and upgrades off the relay make-before-break. Failure is + /// journaled, never fatal — the relay path keeps carrying the session + /// when traversal cannot. + public func authorizeDirectPaths() async { + do { + try await connection.authorizeNatTraversal() + journal.record( + "endpoint", "nat-traversal-authorized", + ["remote": String(remoteEndpointIDHex.prefix(12))] + ) + } catch { + journal.record( + "endpoint", "nat-traversal-authorize-failed", + ["error": String(describing: error)] + ) + } + } + + private func notePong() { + lastPongAt = ContinuousClock.now + } + /// Server-side keepalive responder for one accepted keepalive lane. public nonisolated func respondKeepalive(on lane: IrxLaneStream) -> Task { Task { [journal] in diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxControlByteTransport.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxControlByteTransport.swift index 852dd1031c79..9076bd0d08b6 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxControlByteTransport.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxControlByteTransport.swift @@ -7,19 +7,19 @@ public import Foundation /// /// `establish` supplies the admitted (connection, control-lane) pair: the Mac /// wraps an already-admitted session; the iOS side dials through its peer -/// engine. `closeCode` attributes the QUIC close when the app layer closes -/// this transport (`explicit-redial` on iOS keeps the engine's auto-redial -/// armed for the replacement client; a denial would park it instead). +/// engine. `closeCode` is retained in the construction API for callers that +/// classify lane teardown, but this lane never closes the shared QUIC session. +/// Session ownership belongs to ``IrxPeerEngine``. public actor IrxControlByteTransport: CmxByteTransport { public typealias Establish = @Sendable () async throws -> (IrxConnection, IrxLaneStream) private let establish: Establish - private let closeCode: IrxCloseCode private var pair: (IrxConnection, IrxLaneStream)? private var connectInFlight: Task<(IrxConnection, IrxLaneStream), any Error>? + private var isClosed = false public init(closeCode: IrxCloseCode, establish: @escaping Establish) { - self.closeCode = closeCode + _ = closeCode self.establish = establish } @@ -43,13 +43,21 @@ public actor IrxControlByteTransport: CmxByteTransport { } public func close() async { - guard let (connection, lane) = pair else { return } + isClosed = true + connectInFlight?.cancel() + connectInFlight = nil + guard let (_, lane) = pair else { return } pair = nil + // This is an RPC-lane teardown, not a session teardown. The QUIC + // connection is owned by IrxPeerEngine and may still carry the + // keepalive, event, and terminal lanes. Closing it here made a + // retiring RPC client look like a peer death to the engine. await lane.writer.finish() - await connection.close(code: closeCode, origin: .local) + await lane.reader.stop() } private func establishedPair() async throws -> (IrxConnection, IrxLaneStream) { + guard !isClosed else { throw IrxConnectionError.closed(nil) } if let pair, await !pair.0.isClosed { return pair } @@ -62,6 +70,10 @@ public actor IrxControlByteTransport: CmxByteTransport { connectInFlight = task defer { connectInFlight = nil } let established = try await task.value + guard !isClosed else { + await established.1.close() + throw IrxConnectionError.closed(nil) + } pair = established return established } diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceList.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceList.swift new file mode 100644 index 000000000000..ca23f4b7846c --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceList.swift @@ -0,0 +1,205 @@ +public import Foundation + +/// One account device as the control plane's directory describes it, keyed by +/// its TLS endpoint identity. The judge consults `revoked` only; the rest is +/// carried for supersession keying (`deviceID`), UI status surfaces +/// (`status`), and diagnostics. +public struct IrxDeviceListEntry: Codable, Equatable, Sendable { + public var deviceID: String? + /// Directory lifecycle state: active|seeded|stale|retired|suspended| + /// pending|superseded. Stored verbatim so new server states pass through. + public var status: String + public var revoked: Bool + public var appVersion: String? + public var releaseTrack: String? + public var capabilities: [String]? + public var relayURLHint: String? + /// Extra tuple material for the admitted-peer receipt (present when the + /// directory carries it; admission tolerates absence). + public var bindingID: String? + public var tag: String? + public var identityGeneration: Int? + + public init( + deviceID: String? = nil, + status: String, + revoked: Bool, + appVersion: String? = nil, + releaseTrack: String? = nil, + capabilities: [String]? = nil, + relayURLHint: String? = nil, + bindingID: String? = nil, + tag: String? = nil, + identityGeneration: Int? = nil + ) { + self.deviceID = deviceID + self.status = status + self.revoked = revoked + self.appVersion = appVersion + self.releaseTrack = releaseTrack + self.capabilities = capabilities + self.relayURLHint = relayURLHint + self.bindingID = bindingID + self.tag = tag + self.identityGeneration = identityGeneration + } +} + +/// The authorization lease: the account's device directory at one revision, +/// stamped by the SERVER (`issuedAt` + `ttlSeconds`) and anchored locally to a +/// MONOTONIC receipt instant. Freshness is judged against the monotonic clock +/// so a wall-clock rollback can never revive an expired lease. +public struct IrxDeviceListSnapshot: Equatable, Sendable { + /// Entries keyed by endpoint ID hex (the TLS-authenticated identity). + public var entries: [String: IrxDeviceListEntry] + public var rev: Int + /// Server stamp: when the server issued this directory fact. + public var issuedAt: Date + public var ttlSeconds: Int + /// Server-advertised minimum Mac version, when available. Kept alongside + /// the lease so the UI can explain an outdated host after relaunch. + public var minimumSupportedMacVersion: String? + /// Wall receipt, persisted so a relaunch can bound the lease. + public var receivedAtWall: Date + /// Monotonic receipt, the freshness anchor for this process. + public var receivedAtMonotonic: ContinuousClock.Instant + + public init( + entries: [String: IrxDeviceListEntry], + rev: Int, + issuedAt: Date, + ttlSeconds: Int, + minimumSupportedMacVersion: String? = nil, + receivedAtWall: Date, + receivedAtMonotonic: ContinuousClock.Instant + ) { + self.entries = entries + self.rev = rev + self.issuedAt = issuedAt + self.ttlSeconds = ttlSeconds + self.minimumSupportedMacVersion = minimumSupportedMacVersion + self.receivedAtWall = receivedAtWall + self.receivedAtMonotonic = receivedAtMonotonic + } + + /// The lease holds while the MONOTONIC time since receipt is inside the + /// server-granted TTL. The server stamp anchors the window; the monotonic + /// anchor makes wall-clock tampering irrelevant in-process. + public func isFresh(now: ContinuousClock.Instant) -> Bool { + let elapsed = receivedAtMonotonic.duration(to: now) + return elapsed >= .zero && elapsed < .seconds(ttlSeconds) + } + + /// Re-stamps the lease from an explicit server freshness fact (`current`, + /// or a re-stamped `snapshot_complete`). A freshness frame is not a + /// directory apply, so it must name the directory we already hold and + /// carry a strictly newer server stamp. This prevents replayed frames from + /// extending a revoked or dropped entry's authorization lease. + public func restamped( + rev: Int, + issuedAt: Date, + receivedAtWall: Date, + receivedAtMonotonic: ContinuousClock.Instant + ) -> IrxDeviceListSnapshot? { + guard rev == self.rev, issuedAt > self.issuedAt else { return nil } + var updated = self + updated.rev = rev + updated.issuedAt = issuedAt + updated.receivedAtWall = receivedAtWall + updated.receivedAtMonotonic = receivedAtMonotonic + return updated + } +} + +/// The synchronously readable CURRENT snapshot the accept path judges against. +/// Admission must be O(1) with no actor hop (the judge closure runs inside +/// `IrxAdmission.performServer`), so this is a lock-guarded box the runtime +/// swaps atomically on every directory apply and clears on sign-out. +public final class IrxDeviceListCurrent: @unchecked Sendable { + private let lock = NSLock() + private var snapshot: IrxDeviceListSnapshot? + + public init() {} + + public var current: IrxDeviceListSnapshot? { + lock.lock() + defer { lock.unlock() } + return snapshot + } + + public func replace(_ next: IrxDeviceListSnapshot?) { + lock.lock() + snapshot = next + lock.unlock() + } + + /// Fails closed instantly: with no snapshot, the judge denies everything. + public func clear() { + replace(nil) + } + + /// Applies a freshness re-stamp to the held snapshot, returning the + /// updated value (for persistence) or nil when there is nothing to stamp + /// or the stamp is older than what is held. + @discardableResult + public func restamp( + rev: Int, + issuedAt: Date, + receivedAtWall: Date, + receivedAtMonotonic: ContinuousClock.Instant + ) -> IrxDeviceListSnapshot? { + lock.lock() + defer { lock.unlock() } + guard + let updated = snapshot?.restamped( + rev: rev, + issuedAt: issuedAt, + receivedAtWall: receivedAtWall, + receivedAtMonotonic: receivedAtMonotonic + ) + else { return nil } + snapshot = updated + return updated + } +} + +extension IrxDeviceListSnapshot { + /// Default lease when a pre-list-auth server omits the stamp: one day, + /// mirroring the contract's `ttlSeconds` default. + public static let defaultTTLSeconds = 86_400 + + /// Builds the snapshot from a decoded directory fact. Entries default to + /// `status: "active"`, `revoked: false` when a pre-upgrade server omits + /// the fields: presence in the account directory IS the authorization + /// set, so the defensive default keeps such accounts connectable. + public init( + fact: IrxCtlDirectoryFact, + receivedAtWall: Date, + receivedAtMonotonic: ContinuousClock.Instant + ) { + var entries: [String: IrxDeviceListEntry] = [:] + for binding in fact.payload.bindings { + entries[binding.endpointID] = IrxDeviceListEntry( + deviceID: binding.deviceID, + status: binding.status ?? "active", + revoked: binding.revoked ?? false, + appVersion: binding.appVersion, + releaseTrack: binding.releaseTrack, + capabilities: binding.capabilities, + relayURLHint: binding.homeRelayURL, + bindingID: binding.bindingID, + tag: binding.instanceTag, + identityGeneration: binding.identityGeneration + ) + } + self.init( + entries: entries, + rev: fact.rev, + issuedAt: fact.payload.issuedAt ?? receivedAtWall, + ttlSeconds: fact.payload.ttlSeconds ?? Self.defaultTTLSeconds, + minimumSupportedMacVersion: fact.payload.minimumSupportedVersion?.mac, + receivedAtWall: receivedAtWall, + receivedAtMonotonic: receivedAtMonotonic + ) + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceListStore.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceListStore.swift new file mode 100644 index 000000000000..0876c11b083c --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceListStore.swift @@ -0,0 +1,188 @@ +public import CmuxIrohTransport +public import Foundation + +/// Durable home of the device-list lease, scoped to one (account, backend) +/// pair. Release compositions back it with the Keychain +/// (`com.cmuxterm.irx.device-list.v1`); DEBUG builds use the development file +/// store in the irx state directory, mirroring every other secure store's +/// DEBUG/#else split. +/// +/// Persistence keeps `issuedAt` + the wall receipt. A relaunch has no +/// monotonic continuity, so the load re-anchors the lease from the wall +/// clock and FAILS CLOSED on tampering: any wall regression (now earlier +/// than the recorded receipt) marks the snapshot stale rather than reviving +/// it, and staleness can only be repaired by a fresh server stamp. +public actor IrxDeviceListStore { + /// Bound the server lease value before converting it into a Duration. The + /// deployed broker currently uses one day, and this cap keeps corrupted + /// persisted state from overflowing clock arithmetic on relaunch. + private static let maxPersistedTTLSeconds = 365 * 24 * 60 * 60 + + /// Persisted lease: the snapshot minus the process-local monotonic anchor. + struct PersistedSnapshot: Codable, Equatable, Sendable { + var entries: [String: IrxDeviceListEntry] + var rev: Int + var issuedAt: Date + var ttlSeconds: Int + var minimumSupportedMacVersion: String? + var receivedAtWall: Date + } + + private let secureStore: any CmxIrohSecureCredentialStoring + private let account: String + private let journal: IrxJournal + private let wallNow: @Sendable () -> Date + private let monotonicNow: @Sendable () -> ContinuousClock.Instant + + /// - Parameters: + /// - secureStore: Keychain in Release, development file store in DEBUG. + /// - accountID: The signed-in account whose directory this is. + /// - backendHost: The broker/backend host the directory came from, so + /// staging and production leases can never satisfy each other. + public init( + secureStore: any CmxIrohSecureCredentialStoring, + accountID: String, + backendHost: String, + journal: IrxJournal, + wallNow: @escaping @Sendable () -> Date = { Date() }, + monotonicNow: @escaping @Sendable () -> ContinuousClock.Instant = { .now } + ) { + self.secureStore = secureStore + account = Self.storageAccount(accountID: accountID, backendHost: backendHost) + self.journal = journal + self.wallNow = wallNow + self.monotonicNow = monotonicNow + } + + /// The contract scope is the ordered `(accountID, backendHost)` tuple. The + /// development FILE store only accepts `[A-Za-z0-9._-]` account names, so + /// encode each UTF-8 component as hex instead of replacing characters: + /// lossy normalization would let two scopes share a persisted lease. + static func storageAccount(accountID: String, backendHost: String) -> String { + func hex(_ value: String) -> String { + value.utf8.map { String(format: "%02x", $0) }.joined() + } + return "device-list-v2-\(hex(accountID))-\(hex(backendHost))" + } + + /// Loads the persisted lease, re-anchored to the monotonic clock. + /// + /// A stale-but-decodable lease is RETURNED (with an already-expired + /// monotonic anchor) instead of dropped: callers must distinguish "this + /// device has a directory, but the lease lapsed" (fail closed) from + /// "no directory was ever received" (bootstrap). + public func loadPersisted() async -> IrxDeviceListSnapshot? { + let data: Data? + do { + data = try await secureStore.read(account: account) + } catch { + journal.record( + "device-list", "load-failed", + ["error": String(describing: error)] + ) + return nil + } + guard let data, + let persisted = try? JSONDecoder().decode(PersistedSnapshot.self, from: data) + else { return nil } + guard persisted.ttlSeconds > 0, + persisted.ttlSeconds <= Self.maxPersistedTTLSeconds + else { + journal.record( + "device-list", "invalid-ttl", + ["ttl_seconds": String(persisted.ttlSeconds)] + ) + return nil + } + let now = wallNow() + let anchor = monotonicNow() + let elapsed = now.timeIntervalSince(persisted.receivedAtWall) + let receivedAtMonotonic: ContinuousClock.Instant + if elapsed < 0 { + // Wall clock ran BACKWARD past the recorded receipt: tampering or + // a bad clock. Fail closed by anchoring the lease as already + // expired; only a fresh server stamp revives admission. + journal.record( + "device-list", "wall-clock-rollback", + ["rev": String(persisted.rev)] + ) + // Exactly one TTL ago is already stale (`isFresh` uses `<`), so + // no `+ 1` is needed and this cannot overflow. + receivedAtMonotonic = anchor.advanced( + by: .seconds(-persisted.ttlSeconds)) + } else { + receivedAtMonotonic = anchor.advanced(by: .seconds(-elapsed)) + } + let snapshot = IrxDeviceListSnapshot( + entries: persisted.entries, + rev: persisted.rev, + issuedAt: persisted.issuedAt, + ttlSeconds: persisted.ttlSeconds, + minimumSupportedMacVersion: persisted.minimumSupportedMacVersion, + receivedAtWall: persisted.receivedAtWall, + receivedAtMonotonic: receivedAtMonotonic + ) + journal.record( + "device-list", "loaded", + [ + "rev": String(persisted.rev), + "entries": String(persisted.entries.count), + "fresh": String(snapshot.isFresh(now: anchor)), + ] + ) + return snapshot + } + + @discardableResult + public func persist(_ snapshot: IrxDeviceListSnapshot) async -> Bool { + guard snapshot.ttlSeconds > 0, + snapshot.ttlSeconds <= Self.maxPersistedTTLSeconds + else { + journal.record( + "device-list", "invalid-ttl", + ["ttl_seconds": String(snapshot.ttlSeconds)] + ) + return false + } + let persisted = PersistedSnapshot( + entries: snapshot.entries, + rev: snapshot.rev, + issuedAt: snapshot.issuedAt, + ttlSeconds: snapshot.ttlSeconds, + minimumSupportedMacVersion: snapshot.minimumSupportedMacVersion, + receivedAtWall: snapshot.receivedAtWall + ) + guard let data = try? JSONEncoder().encode(persisted) else { return false } + do { + try await secureStore.write( + data, + account: account, + accessibility: .afterFirstUnlockThisDeviceOnly + ) + journal.record( + "device-list", "persisted", + ["rev": String(snapshot.rev), "entries": String(snapshot.entries.count)] + ) + return true + } catch { + journal.record( + "device-list", "persist-failed", + ["error": String(describing: error)] + ) + return false + } + } + + /// Sign-out: removes this (account, backend) lease. + public func clear() async { + do { + try await secureStore.delete(account: account) + journal.record("device-list", "cleared") + } catch { + journal.record( + "device-list", "clear-failed", + ["error": String(describing: error)] + ) + } + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDiskCache.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDiskCache.swift index ecad5f65ab51..fb959f6739c5 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDiskCache.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDiskCache.swift @@ -15,18 +15,24 @@ public struct IrxDiskCache: Sendable { return try? JSONDecoder().decode(Value.self, from: data) } - public func save(_ value: Value) { - guard let data = try? JSONEncoder().encode(value) else { return } - try? FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700] - ) - try? data.write(to: fileURL, options: .atomic) - // Cached bindings, grants, and relay passes are for this app alone; - // atomic replacement writes a fresh inode, so re-apply owner-only. - try? FileManager.default.setAttributes( - [.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + @discardableResult + public func save(_ value: Value) -> Bool { + guard let data = try? JSONEncoder().encode(value) else { return false } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try data.write(to: fileURL, options: .atomic) + // Cached bindings, grants, and relay passes are for this app alone; + // atomic replacement writes a fresh inode, so re-apply owner-only. + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + return (try Data(contentsOf: fileURL)) == data + } catch { + return false + } } public func clear() { diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxGrantJudge.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxGrantJudge.swift index 3c6c5ed9369f..b3becb5d9fa9 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxGrantJudge.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxGrantJudge.swift @@ -30,6 +30,11 @@ public struct IrxGrantJudge: Sendable { let trustProvider = trustProvider let revokedGrantIDs = revokedGrantIDs return { grantJWS, remoteEndpointIDHex in + // Grant verification needs a grant; a grantless (list-auth v2) + // hello reaching this judge is a composition error, denied loud. + guard let grantJWS else { + throw IrxAdmissionDenied(code: .invalidGrant) + } guard let trust = trustProvider() else { throw IrxAdmissionDenied(code: .invalidGrant) } diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxJSONCache.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxJSONCache.swift new file mode 100644 index 000000000000..d6e4b2e7f30f --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxJSONCache.swift @@ -0,0 +1,185 @@ +public import Foundation +import Security + +/// Synchronous JSON cache seam behind the broker's persisted state +/// (binding / trust / relay-credentials / grants). Synchronous by contract: +/// `IrxBrokerService.init` reads the binding cache to arm request signing +/// before any async context exists, and the admission path reads trust with +/// no actor hop. +public protocol IrxJSONCache: Sendable { + associatedtype Value: Codable & Sendable + func load() -> Value? + @discardableResult func save(_ value: Value) -> Bool + func clear() +} + +extension IrxDiskCache: IrxJSONCache {} + +/// Keychain-backed JSON cache for Release builds. +/// +/// Mirrors ``CmxIrohKeychainCredentialStore``'s exact item shape (device-only +/// generic password, data-protection keychain, AfterFirstUnlockThisDeviceOnly, +/// optional access group) but through synchronous SecItem calls, because the +/// broker's init-time signing arm and the admission path cannot await an +/// actor. RECONCILE: if the shared store ever grows a synchronous facade, +/// fold this onto it. +public struct IrxKeychainJSONCache: IrxJSONCache { + public static var service: String { "com.cmuxterm.irx.cache.v1" } + + private let account: String + private let accessGroup: String? + + /// - Parameter account: `"||"`, so one + /// keychain service hosts every cache kind without collisions across + /// accounts or environments. + public init(account: String, accessGroup: String? = nil) { + self.account = account + self.accessGroup = accessGroup + } + + public func load() -> Value? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return try? JSONDecoder().decode(Value.self, from: data) + } + + @discardableResult + public func save(_ value: Value) -> Bool { + guard let data = try? JSONEncoder().encode(value) else { return false } + let query = baseQuery() + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let updateStatus = SecItemUpdate( + query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return load() != nil } + guard updateStatus == errSecItemNotFound else { return false } + var insert = query + attributes.forEach { insert[$0.key] = $0.value } + let addStatus = SecItemAdd(insert as CFDictionary, nil) + if addStatus == errSecSuccess { return load() != nil } + if addStatus == errSecDuplicateItem { + let retry = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + return retry == errSecSuccess && load() != nil + } + return false + } + + public func clear() { + _ = SecItemDelete(baseQuery() as CFDictionary) + } + + private func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: false, + kSecUseDataProtectionKeychain as String: true, + ] + if let accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } +} + +/// One-way, idempotent file-to-primary migration: the first load that finds +/// the primary empty but a legacy file present copies the value into the +/// primary and deletes the file. Every later read is primary-only, and a +/// resurrected legacy file is ignored while the primary holds a value. +public struct IrxMigratingJSONCache< + Primary: IrxJSONCache, Legacy: IrxJSONCache +>: IrxJSONCache, Sendable where Primary.Value == Legacy.Value { + public typealias Value = Primary.Value + + private let primary: Primary + private let legacy: Legacy + + public init(primary: Primary, legacy: Legacy) { + self.primary = primary + self.legacy = legacy + } + + public func load() -> Value? { + if let value = primary.load() { return value } + guard let migrated = legacy.load() else { return nil } + // Keep the legacy copy when the primary is unavailable. Keychain + // writes can fail while the device is locked or entitlements are + // misconfigured; deleting the only readable copy would make the + // account unrecoverable on the next launch. + guard primary.save(migrated) else { return migrated } + legacy.clear() + return migrated + } + + @discardableResult + public func save(_ value: Value) -> Bool { + guard primary.save(value) else { return false } + // A save supersedes anything the legacy file held; drop it so a + // later primary clear can never resurrect stale state. + legacy.clear() + return true + } + + public func clear() { + primary.clear() + legacy.clear() + } +} + +/// Scope identifiers for the Release keychain caches. Threaded through +/// ``IrxBrokerService/Configuration`` because the keychain account is +/// `||` and neither identifier is derivable +/// from the cache directory alone. +public struct IrxBrokerCacheScope: Sendable, Equatable { + public var accountID: String + public var backendHost: String + public var keychainAccessGroup: String? + + public init( + accountID: String, + backendHost: String, + keychainAccessGroup: String? = nil + ) { + self.accountID = accountID + self.backendHost = backendHost + self.keychainAccessGroup = keychainAccessGroup + } +} + +enum IrxBrokerCacheFactory { + /// DEBUG: the byte-identical JSON file (dev tooling reads the state dir). + /// Release: keychain-backed when the signed-in account scope is known. + /// Unscoped legacy files are retained but never imported: their old format + /// has no account/backend owner, so migration could hand one account + /// another account's binding or credentials. Before identity is known, the + /// file remains the temporary store. + static func make( + kind: String, + fileURL: URL, + scope: IrxBrokerCacheScope? + ) -> any IrxJSONCache { + let file = IrxDiskCache(fileURL: fileURL) + #if DEBUG + return file + #else + guard let scope else { return file } + // A legacy snapshot is not account/backend scoped. Never import it + // into the scoped keychain item, even when its shape happens to decode. + // Preserve the old copy until an explicit, identity-validated + // migration can replace it; construction must never destroy the only + // offline binding or credential state during an upgrade. + return IrxKeychainJSONCache( + account: "\(kind)|\(scope.accountID)|\(scope.backendHost)", + accessGroup: scope.keychainAccessGroup + ) + #endif + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxListJudge.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxListJudge.swift new file mode 100644 index 000000000000..5d52166c1b75 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxListJudge.swift @@ -0,0 +1,62 @@ +public import Foundation + +/// Builds the server-side LIST judgment: admit a peer iff the current +/// device-list lease is present and fresh, the TLS-authenticated endpoint is +/// in it, and its entry is not revoked. The hello's grant is ignored +/// entirely (old phones still present one; it carries no authority here). +/// +/// The judgment is synchronous and O(1): it reads the atomically swapped +/// ``IrxDeviceListCurrent`` box, never an actor and never the network, so +/// admission latency is unchanged from the grant judge it replaces. +public struct IrxListJudge: Sendable { + private let current: IrxDeviceListCurrent + private let journal: IrxJournal + private let now: @Sendable () -> ContinuousClock.Instant + + public init( + current: IrxDeviceListCurrent, + journal: IrxJournal, + now: @escaping @Sendable () -> ContinuousClock.Instant = { .now } + ) { + self.current = current + self.journal = journal + self.now = now + } + + public func judgment() -> IrxGrantJudgment { + let current = current + let journal = journal + let now = now + return { _, remoteEndpointIDHex in + let deny: @Sendable (String, IrxCloseCode) -> IrxAdmissionDenied = { reason, code in + journal.record( + "admission", "list-deny", + [ + "reason": reason, + "remote": String(remoteEndpointIDHex.prefix(12)), + ] + ) + return IrxAdmissionDenied(code: code) + } + guard let snapshot = current.current else { + throw deny("absent", .invalidGrant) + } + guard snapshot.isFresh(now: now()) else { + throw deny("stale", .invalidGrant) + } + guard let entry = snapshot.entries[remoteEndpointIDHex] else { + throw deny("unknown-endpoint", .invalidGrant) + } + if entry.revoked { + throw deny("revoked", .revoked) + } + return IrxAdmittedPeerInfo( + bindingID: entry.bindingID ?? "", + deviceID: entry.deviceID ?? remoteEndpointIDHex, + tag: entry.tag ?? "", + endpointIDHex: remoteEndpointIDHex, + identityGeneration: entry.identityGeneration ?? 1 + ) + } + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxPeerEngine.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxPeerEngine.swift index f781bcc8dab2..39828180a98a 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxPeerEngine.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxPeerEngine.swift @@ -16,17 +16,23 @@ public struct IrxClientSession: Sendable { public let admit: IrxAdmit public let control: IrxLaneStream public let establishedAt: Date + /// Monotonic counterpart used for liveness decisions. Wall-clock time is + /// retained for diagnostics only because clock rollback must not suppress + /// a foreground zombie replacement. + public let establishedAtMonotonic: ContinuousClock.Instant public init( connection: IrxConnection, admit: IrxAdmit, control: IrxLaneStream, - establishedAt: Date + establishedAt: Date, + establishedAtMonotonic: ContinuousClock.Instant = .now ) { self.connection = connection self.admit = admit self.control = control self.establishedAt = establishedAt + self.establishedAtMonotonic = establishedAtMonotonic } } @@ -62,6 +68,11 @@ public actor IrxPeerEngine { private var session: IrxClientSession? private var state: IrxSessionState = .idle private var dialTask: Task? + /// Monotonic owner token for the dial slot. Cancelling a task does not + /// guarantee that its underlying transport stops before its waiter + /// resumes, so completion must prove it still owns the slot before it can + /// clear or adopt anything. + private var dialGeneration: UInt64 = 0 private var redialTimer: Task? private var terminationWatcher: Task? private var backoff: Duration @@ -123,7 +134,7 @@ public actor IrxPeerEngine { /// This is the ONLY dial path; `explicit` overrides a parked denial and /// replaces any in-flight attempt. public func ensureSession(explicit: Bool = false, trigger: String) async throws -> IrxClientSession { - if let session, await !session.connection.isClosed { + if let session, await !session.connection.isClosed, !explicit { return session } if let parkedCode, !explicit { @@ -131,14 +142,35 @@ public actor IrxPeerEngine { code: IrxCloseCode(rawValue: parkedCode) ?? .invalidGrant) } if explicit { + // An explicit replacement invalidates the old session before the + // new dial starts. Keeping it here after a failed replacement + // makes currentSession() return a zombie and suppresses every + // subsequent automatic dial. + let previous = session + session = nil + terminationWatcher?.cancel() + terminationWatcher = nil parkedCode = nil cooldownUntil = nil - dialTask?.cancel() - dialTask = nil + invalidateDial() + if let previous { + Task { + await previous.connection.close( + code: .explicitRedial, + origin: .local + ) + } + } } if let dialTask { record("dial-joined", ["trigger": trigger]) - return try await dialTask.value + let generation = dialGeneration + let joined = try await dialTask.value + guard dialGeneration == generation else { + await joined.connection.close(code: .explicitRedial, origin: .local) + throw CancellationError() + } + return joined } if !explicit, let cooldownUntil, ContinuousClock.now < cooldownUntil { // The scheduled redial owns the next attempt; fail fast instead @@ -149,24 +181,36 @@ public actor IrxPeerEngine { redialTimer = nil setState(.connecting) record("dial-started", ["trigger": trigger]) + dialGeneration &+= 1 + let generation = dialGeneration let task = Task { try await self.dialOnce() } dialTask = task do { let established = try await task.value + guard dialGeneration == generation else { + await established.connection.close( + code: .explicitRedial, origin: .local) + throw CancellationError() + } dialTask = nil adopt(established) return established } catch let denial as IrxAdmissionDenied { + guard dialGeneration == generation else { throw denial } dialTask = nil parkedCode = denial.code.rawValue setState(.closed(code: denial.code.rawValue)) record("dial-denied", ["code": denial.code.rawValue]) throw denial } catch { + guard dialGeneration == generation else { throw error } dialTask = nil - guard !Task.isCancelled else { throw error } + // Cancellation is always owner-driven (stop(), an explicit + // replacement, or a hint-race redial); the canceller owns the + // next state, so no failure bookkeeping and no redial schedule. + if error is CancellationError || Task.isCancelled { throw error } lastDialError = error setState(.closed(code: "dial-failed")) record( @@ -184,6 +228,57 @@ public actor IrxPeerEngine { Task { _ = try? await self.ensureSession(trigger: trigger) } } + /// Foreground resume: a session that has not proven liveness recently is + /// treated as a zombie (a suspension can kill the QUIC connection without + /// isClosed flipping) and replaced IMMEDIATELY — close + explicit redial — + /// instead of waiting out keepalive strike detection. Fresh sessions and + /// no-session states fall through to a normal warm-up. + public func foregroundKick(staleAfter: Duration = .seconds(15)) { + Task { + if let session = await self.currentSessionForKick() { + // Liveness evidence is a recent pong OR a recent admission: a + // just-established session has no pong yet and must not be + // executed as a zombie while its first keepalive is in flight + // (that exact race produced the foreground redial storm). + let now = ContinuousClock.now + let pongAge = (await session.connection.lastPongAt).map { now - $0 } + let sessionAge = session.establishedAtMonotonic.duration(to: now) + let liveness = pongAge.map { min($0, sessionAge) } ?? sessionAge + if liveness > staleAfter { + self.record("foreground-stale-redial", [:]) + _ = try? await self.ensureSession(explicit: true, trigger: "foreground-stale") + return + } + } + _ = try? await self.ensureSession(trigger: "foreground") + } + } + + private func currentSessionForKick() -> IrxClientSession? { + session + } + + /// Event-driven relay race: fresh discovery just revealed a different + /// home relay for this peer. An admitted session passing keepalives is + /// never touched. An in-flight dial (aimed at the stale relay, where it + /// would sit out a silent black-hole timeout) is cancelled and replaced + /// immediately; a pending backoff redial is pulled forward. Parked + /// denials stay parked: authorization state is not a routing question. + public func relayHintChanged(trigger: String) { + if case .ready = state { return } + if parkedCode != nil { return } + guard dialTask != nil || redialTimer != nil || cooldownUntil != nil else { + return + } + record("hint-race-redial", ["trigger": trigger]) + invalidateDial() + redialTimer?.cancel() + redialTimer = nil + cooldownUntil = nil + backoff = config.initialBackoff + Task { _ = try? await self.ensureSession(trigger: trigger) } + } + public func currentSession() async -> IrxClientSession? { if let session, await !session.connection.isClosed { return session @@ -195,8 +290,7 @@ public actor IrxPeerEngine { public func stop(code: IrxCloseCode = .userRequested) async { redialTimer?.cancel() redialTimer = nil - dialTask?.cancel() - dialTask = nil + invalidateDial() terminationWatcher?.cancel() terminationWatcher = nil if let session { @@ -208,12 +302,26 @@ public actor IrxPeerEngine { } private func adopt(_ established: IrxClientSession) { + let previous = session session = established backoff = config.initialBackoff parkedCode = nil setState(.ready(session: established.admit.session)) watchTermination(of: established) startKeepalive(of: established) + // Replacement is make-before-break. The new session is published and + // keepalive-armed before the old connection is closed, so a planned + // RPC/client handoff cannot create a peer-visible outage. The old + // termination watcher is already cancelled by watchTermination. + if let previous, + previous.admit.session != established.admit.session { + Task { + await previous.connection.close( + code: .explicitRedial, + origin: .local + ) + } + } } private func startKeepalive(of established: IrxClientSession) { @@ -298,6 +406,12 @@ public actor IrxPeerEngine { } } + private func invalidateDial() { + dialGeneration &+= 1 + dialTask?.cancel() + dialTask = nil + } + private func removeStateContinuation(_ id: Int) { stateContinuations[id] = nil } diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxProtocol.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxProtocol.swift index 6b016708fd18..3f6708985571 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxProtocol.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxProtocol.swift @@ -18,6 +18,11 @@ public enum IrxProtocol { /// keeping worst-case detection-plus-redial inside single-digit seconds. public static let keepaliveInterval: Duration = .seconds(5) public static let keepaliveDeadline: Duration = .seconds(2) + /// Consecutive pong misses before the connection is declared dead. One + /// transient stall (relay hiccup, brief peer pause) must never sever a + /// healthy session; a re-ping fires immediately after a miss, so real + /// death still detects in ~strikeLimit x deadline. + public static let keepaliveStrikeLimit = 2 } /// Machine-readable close/denial codes. The code travels in the QUIC @@ -114,15 +119,16 @@ public struct IrxLaneDescriptor: Codable, Equatable, Sendable { } /// Client -> server admission request, first frame on the control stream. -/// The grant is the broker-signed pair grant; everything the server needs to -/// judge admission is in the grant plus the TLS-authenticated key, so -/// admission is one round trip and needs no backend call. +/// List-auth hellos carry NO grant: the server judges the TLS-authenticated +/// key against its device-list snapshot, so admission stays one round trip +/// with no backend call. The grant field remains OPTIONAL on the wire so an +/// old peer's grant-bearing hello still parses (the list judge ignores it). public struct IrxHello: Codable, Equatable, Sendable { public var v: Int public var proto: String - public var grant: String + public var grant: String? - public init(grant: String) { + public init(grant: String? = nil) { v = IrxProtocol.version proto = IrxProtocol.alpn self.grant = grant diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxServerSessionRegistry.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxServerSessionRegistry.swift index ec2858ad87f8..8803d2c9e8b7 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxServerSessionRegistry.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxServerSessionRegistry.swift @@ -19,16 +19,31 @@ public actor IrxServerSessionRegistry { public func admit( deviceID: String, sessionID: String, - connection: IrxConnection - ) async { - if let previous = sessionsByDevice[deviceID] { + connection: IrxConnection, + stillAuthorized: @escaping @Sendable (_ remoteEndpointIDHex: String) -> Bool = { _ in true } + ) async -> Bool { + // Admission and registration are separate async phases. Re-check the + // atomically readable list immediately before publishing the session + // so a directory revocation that lands between them cannot leave a + // newly admitted connection outside the enforcement sweep. + guard stillAuthorized(connection.remoteEndpointIDHex) else { + journal.record( + "registry", "admit-revoked", + ["device": deviceID, "session": sessionID] + ) + await connection.close(code: .revoked, origin: .local) + return false + } + let previous = sessionsByDevice.updateValue( + (sessionID, connection), forKey: deviceID) + if let previous { journal.record( "registry", "superseded", ["device": deviceID, "old_session": previous.session, "new_session": sessionID] ) await previous.connection.close(code: .superseded, origin: .local) } - sessionsByDevice[deviceID] = (sessionID, connection) + return true } /// Removes a session when its supervisor exits, unless a newer session @@ -39,9 +54,41 @@ public actor IrxServerSessionRegistry { } public func closeAll(code: IrxCloseCode) async { - for entry in sessionsByDevice.values { + let entries = Array(sessionsByDevice) + for (deviceID, entry) in entries { + await entry.connection.close(code: code, origin: .local) + if sessionsByDevice[deviceID]?.session == entry.session { + sessionsByDevice[deviceID] = nil + } + } + } + + /// Closes every live session whose TLS-authenticated peer endpoint the + /// predicate selects (directory enforcement: a device revoked or dropped + /// from the list is cut NOW, not at its next admission). + public func closeAll( + code: IrxCloseCode, + matching shouldClose: @Sendable (_ remoteEndpointIDHex: String) -> Bool + ) async { + // Snapshot before awaiting connection shutdown. Actor reentrancy can + // admit or replace sessions while a close is in flight, and mutating + // the live dictionary during iteration would otherwise invalidate the + // collection and skip entries. + let entries = Array(sessionsByDevice) + for (deviceID, entry) in entries + where shouldClose(entry.connection.remoteEndpointIDHex) { + journal.record( + "registry", "list-enforced-close", + [ + "device": deviceID, + "session": entry.session, + "code": code.rawValue, + ] + ) await entry.connection.close(code: code, origin: .local) + if sessionsByDevice[deviceID]?.session == entry.session { + sessionsByDevice[deviceID] = nil + } } - sessionsByDevice.removeAll() } } diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxBrokerMintRetryTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxBrokerMintRetryTests.swift new file mode 100644 index 000000000000..bc3a413af8e3 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxBrokerMintRetryTests.swift @@ -0,0 +1,333 @@ +import CryptoKit +import Foundation +import Network +import Testing + +@testable import CmuxIrohTransport +@testable import CmuxIrxTransport + +/// Reproduces the deterministic first-mint failure from the 2026-08-26 irx +/// soak (issue #10924): the credential autopilot's mint POST rides a pooled +/// keep-alive connection that the broker edge closed while the client slept +/// its ~3-4 minute refresh interval. The next POST writes into the dead +/// socket, the first read returns POSIX 54 (ECONNRESET), and URLSession +/// surfaces NSURLErrorNetworkConnectionLost (-1005) without a transparent +/// retry because a POST with bytes written is not retried (Apple QA1941). +/// +/// The in-process server below makes that sequence exact: it serves the first +/// mint on a keep-alive connection, then resets that same connection when the +/// next request arrives on it, then serves normally on fresh connections. +@Suite(.serialized) +struct IrxBrokerMintRetryTests { + /// The soak-observed failure: the second mint of a session lands on the + /// stale pooled connection. The broker service must classify the -1005, + /// retry the idempotent mint once immediately on the fresh connection the + /// purged pool now provides, and succeed within the same cycle instead of + /// surfacing `mint-failed connectivity` and waiting for the ~64s outer + /// autopilot retry. + @Test + func mintRetriesOnceWhenPooledConnectionDiesBetweenCycles() async throws { + let server = try await IrxStaleKeepAliveHTTPServer.start { requestIndex in + requestIndex == 1 ? .resetConnection : .respond + } + defer { server.stop() } + let journal = IrxJournal(subsystem: "dev.cmux.irx-tests", category: "mint-retry") + let service = try Self.makeBrokerService(port: server.port, journal: journal) + server.mintResponseBody = Self.mintResponseBody( + endpointIDHex: Self.testIdentity.endpointIDHex + ) + + let first = try await service.mintRelayCredentials() + #expect(!first.isEmpty) + + // Second cycle: first attempt hits the reset pooled connection. The + // fix retries once, immediately, on a fresh connection. + let second = try await service.mintRelayCredentials() + #expect(!second.isEmpty) + + // Exactly one retry: 1 (first mint) + 2 (failed attempt + retry). + #expect(server.requestCount == 3) + + let retried = journal.tail().filter { $0.event == "relay-mint-retried" } + #expect(retried.count == 1) + #expect( + retried.first?.attributes["error"]?.contains("networkConnectionLost(-1005)") + == true + ) + } + + /// When the immediate retry also fails, the surfaced error (which the + /// autopilot journals as `mint-failed a_error`) must carry the underlying + /// NSURLError classification instead of collapsing to bare "connectivity". + @Test + func mintFailureCarriesUnderlyingURLErrorCode() async throws { + let server = try await IrxStaleKeepAliveHTTPServer.start { requestIndex in + requestIndex == 0 ? .respond : .resetConnection + } + defer { server.stop() } + let journal = IrxJournal(subsystem: "dev.cmux.irx-tests", category: "mint-attrib") + let service = try Self.makeBrokerService(port: server.port, journal: journal) + server.mintResponseBody = Self.mintResponseBody( + endpointIDHex: Self.testIdentity.endpointIDHex + ) + + let first = try await service.mintRelayCredentials() + #expect(!first.isEmpty) + + do { + _ = try await service.mintRelayCredentials() + Issue.record("second mint unexpectedly succeeded; server resets every attempt") + } catch { + let description = String(describing: error) + #expect( + description.contains("networkConnectionLost(-1005)"), + "mint failure must attribute the URL error, got: \(description)" + ) + } + + // One attempt plus exactly one immediate retry, never a retry loop. + #expect(server.requestCount == 3) + } + + // MARK: - Fixtures + + private static let testIdentity = IrxIdentity( + privateKeyData: Curve25519.Signing.PrivateKey().rawRepresentation, + deviceID: "irx-mint-retry-test-device", + appInstanceID: "b2fb2f6e-1111-4222-8333-444455556666" + ) + + private static func makeBrokerService( + port: UInt16, + journal: IrxJournal + ) throws -> IrxBrokerService { + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("irx-mint-retry-tests-\(UUID().uuidString)") + guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else { + throw IrxBrokerServiceError.invalidIdentity + } + return try IrxBrokerService( + configuration: IrxBrokerService.Configuration( + baseURL: baseURL, + clientNamespace: "dev.cmux.irx-tests", + tag: "irx-tests", + platform: .ios, + displayName: nil, + cacheDirectory: cacheDirectory + ), + identity: testIdentity, + accessTokenPair: { ("test-access-token", "test-refresh-token") }, + journal: journal + ) + } + + /// A minimal valid `/api/relay/token` bootstrap response: one managed + /// relay credential bound to the test endpoint plus the signed-policy + /// triplet the bootstrap path requires (shape-validated only). + private static func mintResponseBody(endpointIDHex: String) -> Data { + let now = Int64(Date().timeIntervalSince1970) + let json = """ + { + "endpointId": "\(endpointIDHex)", + "relayCredentials": [ + { + "relayUrl": "https://usc1.relay.cmux.dev/", + "token": "irx-test-relay-token", + "expiresAt": \(now + 300), + "refreshAfter": \(now + 240), + "ttlSeconds": 300 + } + ], + "policy": "eyJhbGciOiJFZERTQSJ9.eyJwb2xpY3kiOjF9.c2lnbmF0dXJl", + "preference": { "mode": "automatic" }, + "preferenceRevision": 1 + } + """ + return Data(json.utf8) + } +} + +/// Minimal in-process HTTP/1.1 keep-alive server whose per-request script can +/// reset the underlying TCP connection instead of answering, which is how a +/// broker edge's silent idle close surfaces to the client: the request bytes +/// are written, then the first read fails with ECONNRESET. +final class IrxStaleKeepAliveHTTPServer: @unchecked Sendable { + enum RequestAction: Sendable { + case respond + case resetConnection + } + + private let listener: NWListener + private let queue = DispatchQueue(label: "irx-stale-keepalive-http-server") + private let lock = NSLock() + private let action: @Sendable (Int) -> RequestAction + private var servedRequestCount = 0 + private var openConnections: [NWConnection] = [] + private var responseBody = Data("{}".utf8) + + /// Bound loopback port; valid once the listener reports ready. + var port: UInt16 { listener.port?.rawValue ?? 0 } + + var requestCount: Int { + lock.lock() + defer { lock.unlock() } + return servedRequestCount + } + + var mintResponseBody: Data { + get { + lock.lock() + defer { lock.unlock() } + return responseBody + } + set { + lock.lock() + defer { lock.unlock() } + responseBody = newValue + } + } + + static func start( + action: @escaping @Sendable (Int) -> RequestAction + ) async throws -> IrxStaleKeepAliveHTTPServer { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = NWEndpoint.hostPort( + host: .ipv4(.loopback), port: .any + ) + let listener = try NWListener(using: parameters) + return try await withCheckedThrowingContinuation { continuation in + let server = IrxStaleKeepAliveHTTPServer(listener: listener, action: action) + let resumer = OnceResumer(continuation: continuation) + listener.stateUpdateHandler = { state in + switch state { + case .ready: + resumer.resume(.success(server)) + case let .failed(error): + resumer.resume(.failure(error)) + default: + break + } + } + listener.start(queue: server.queue) + } + } + + /// Resumes a checked continuation at most once across listener callbacks. + private final class OnceResumer: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(_ result: Result) { + lock.lock() + let continuation = self.continuation + self.continuation = nil + lock.unlock() + continuation?.resume(with: result) + } + } + + private init( + listener: NWListener, + action: @escaping @Sendable (Int) -> RequestAction + ) { + self.listener = listener + self.action = action + listener.newConnectionHandler = { [weak self] connection in + self?.adopt(connection) + } + } + + func stop() { + listener.cancel() + lock.lock() + let connections = openConnections + openConnections = [] + lock.unlock() + for connection in connections { + connection.cancel() + } + } + + private func adopt(_ connection: NWConnection) { + lock.lock() + openConnections.append(connection) + lock.unlock() + connection.start(queue: queue) + receiveLoop(connection, buffer: Data()) + } + + private func receiveLoop(_ connection: NWConnection, buffer: Data) { + connection.receive( + minimumIncompleteLength: 1, maximumLength: 128 * 1024 + ) { [weak self] data, _, isComplete, error in + guard let self, error == nil else { return } + var buffer = buffer + if let data, !data.isEmpty { + buffer.append(data) + } + let stillOpen = self.drainCompleteRequests(&buffer, on: connection) + if stillOpen, !isComplete { + self.receiveLoop(connection, buffer: buffer) + } + } + } + + /// Consumes every complete HTTP request in `buffer`, applying the script. + /// Returns false once the connection has been reset. + private func drainCompleteRequests( + _ buffer: inout Data, on connection: NWConnection + ) -> Bool { + while let request = Self.completeRequestLength(in: buffer) { + buffer.removeSubrange(buffer.startIndex ..< buffer.startIndex + request) + lock.lock() + let index = servedRequestCount + servedRequestCount += 1 + let body = responseBody + lock.unlock() + switch action(index) { + case .respond: + let head = + "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: \(body.count)\r\n" + + "Connection: keep-alive\r\n\r\n" + connection.send( + content: Data(head.utf8) + body, + completion: .contentProcessed { _ in } + ) + case .resetConnection: + // RST, exactly like the edge tearing down the idle pooled + // connection: the client's written request is never answered + // and its next read fails with ECONNRESET (POSIX 54). + connection.forceCancel() + return false + } + } + return true + } + + /// Total byte length of the first complete request in `buffer`, or nil. + private static func completeRequestLength(in buffer: Data) -> Int? { + guard let headerRange = buffer.range(of: Data("\r\n\r\n".utf8)) else { + return nil + } + let headerData = buffer[buffer.startIndex ..< headerRange.lowerBound] + let headerText = String(decoding: headerData, as: UTF8.self) + var contentLength = 0 + for line in headerText.split(separator: "\r\n") { + let parts = line.split(separator: ":", maxSplits: 1) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespaces).lowercased() + == "content-length", + let value = Int(parts[1].trimmingCharacters(in: .whitespaces)) + else { continue } + contentLength = value + } + let total = (headerRange.upperBound - buffer.startIndex) + contentLength + return buffer.count >= total ? total : nil + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxControlPlaneTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxControlPlaneTests.swift new file mode 100644 index 000000000000..368bf0b88126 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxControlPlaneTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing + +@testable import CmuxIrxTransport + +/// Golden-fixture contract tests: every checked-in control-plane fixture must +/// decode into the generated wire types. The worker's test suite decodes the +/// SAME files into the generated TypeScript types, so the two platforms can +/// only drift by failing one of these suites. +@Suite struct IrxControlPlaneWireTests { + private static let fixturesDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // IrxControlPlaneTests.swift + .deletingLastPathComponent() // CmuxIrxTransportTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // CmuxIrxTransport + .deletingLastPathComponent() // Shared + .deletingLastPathComponent() // Packages + .appendingPathComponent("schemas/control-plane/fixtures") + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + let iso = ISO8601DateFormatter() + decoder.dateDecodingStrategy = .custom { decoder in + let raw = try decoder.singleValueContainer().decode(String.self) + guard let date = iso.date(from: raw) else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "unparseable date: \(raw)")) + } + return date + } + return decoder + }() + + private func fixture(_ name: String) throws -> Data { + try Data(contentsOf: Self.fixturesDirectory.appendingPathComponent("\(name).json")) + } + + @Test func relayPassesFixtureDecodes() throws { + let fact = try Self.decoder.decode(CTLRelayPasses.self, from: fixture("relay-passes")) + #expect(fact.v == 1) + #expect(fact.rev == 42) + #expect(fact.payload.endpointID == "0fbffe130b96") + #expect(fact.payload.passes.count == 1) + #expect(fact.payload.passes[0].relayURL == "https://usw1.relay.cmux.dev/") + #expect(fact.payload.passes[0].expiresAt > fact.payload.passes[0].refreshAfter) + } + + @Test func directoryFixtureDecodes() throws { + let fact = try Self.decoder.decode(CTLDirectory.self, from: fixture("directory")) + #expect(fact.payload.bindings.count == 2) + #expect(fact.payload.bindings[0].homeRelayURL == "https://usw1.relay.cmux.dev/") + #expect(fact.payload.relayFleet.count == 2) + #expect(fact.payload.grantVerificationKeys.count == 1) + // List-auth lease stamp + per-entry authorization state. + #expect(fact.payload.ttlSeconds == 86_400) + #expect(fact.payload.bindings[0].revoked == false) + #expect(fact.payload.bindings[1].status == .seeded) + #expect(fact.payload.bindings[1].revoked == true) + } + + /// The hand-written tolerant overlay must decode the same golden fixture + /// the generated type does, or list-auth silently diverges from the wire. + @Test func directoryFixtureDecodesThroughListAuthOverlay() throws { + let fact = try Self.decoder.decode( + IrxCtlDirectoryFact.self, from: fixture("directory")) + #expect(fact.rev == 42) + #expect(fact.payload.issuedAt != nil) + #expect(fact.payload.ttlSeconds == 86_400) + let snapshot = IrxDeviceListSnapshot( + fact: fact, receivedAtWall: Date(), receivedAtMonotonic: .now) + #expect(snapshot.entries.count == 2) + #expect(snapshot.entries["0fbffe130b96"]?.revoked == false) + #expect(snapshot.entries["8de4b1c22a10"]?.status == "seeded") + #expect(snapshot.entries["8de4b1c22a10"]?.revoked == true) + } + + @Test func hintUpdateFixtureDecodes() throws { + let fact = try Self.decoder.decode(CTLHintUpdate.self, from: fixture("hint-update")) + #expect(fact.payload.homeRelayURL == "https://use4.relay.cmux.dev/") + } + + @Test func controlFrameFixturesDecode() throws { + _ = try Self.decoder.decode(CTLHelloACK.self, from: fixture("hello-ack")) + _ = try Self.decoder.decode(CTLSnapshotComplete.self, from: fixture("snapshot-complete")) + _ = try Self.decoder.decode(CTLError.self, from: fixture("control-error")) + _ = try Self.decoder.decode(CTLHello.self, from: fixture("hello")) + _ = try Self.decoder.decode(CTLMintRequest.self, from: fixture("mint-request")) + _ = try Self.decoder.decode(CTLPublishHint.self, from: fixture("publish-hint")) + } + + /// Round-trip: encoding what we decoded re-parses identically, so the + /// client can never emit a frame the schema disallows structurally. + @Test func helloRoundTrips() throws { + let hello = try Self.decoder.decode(CTLHello.self, from: fixture("hello")) + let encoded = try JSONEncoder().encode(hello) + let again = try Self.decoder.decode(CTLHello.self, from: encoded) + #expect(hello == again) + } +} + +/// The event-driven relay race on the reconnect owner. +@Suite struct IrxPeerEngineHintRaceTests { + private final class DialGate: @unchecked Sendable { + private let lock = NSLock() + private var started = 0 + private var release: [CheckedContinuation] = [] + + func dialStarted() { lock.lock(); started += 1; lock.unlock() } + var dialCount: Int { lock.lock(); defer { lock.unlock() }; return started } + func hold() async { + await withCheckedContinuation { continuation in + lock.lock() + release.append(continuation) + lock.unlock() + } + } + func releaseAll() { + lock.lock() + let pending = release + release = [] + lock.unlock() + pending.forEach { $0.resume() } + } + } + + @Test func hintChangeCancelsInFlightDialAndRedials() async throws { + let journal = IrxJournal(subsystem: "test", category: "hint-race", journalFileURL: nil) + let gate = DialGate() + let engine = IrxPeerEngine(journal: journal, label: "test") { + gate.dialStarted() + // First dial parks forever against the stale relay (the silent + // black hole); the race must cancel it rather than wait it out. + await gate.hold() + throw IrxConnectionError.closed(nil) + } + await engine.warmUp(trigger: "test-warmup") + // Wait until the first dial is actually in flight. + for _ in 0..<100 where gate.dialCount == 0 { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(gate.dialCount == 1) + + await engine.relayHintChanged(trigger: "test-hint") + // The race cancels dial 1 and starts dial 2 without any timer wait. + for _ in 0..<100 where gate.dialCount < 2 { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(gate.dialCount == 2) + gate.releaseAll() + } + + @Test func hintChangeNeverTouchesIdleEngine() async throws { + let journal = IrxJournal(subsystem: "test", category: "hint-idle", journalFileURL: nil) + let gate = DialGate() + let engine = IrxPeerEngine(journal: journal, label: "test") { + gate.dialStarted() + throw IrxConnectionError.closed(nil) + } + await engine.relayHintChanged(trigger: "test-idle") + // `relayHintChanged` completes after the engine has processed the + // event. Yield once so any incorrectly scheduled dial task gets a + // chance to run, without introducing a timing-based assertion. + await Task.yield() + #expect(gate.dialCount == 0) + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxCtlListAuthTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxCtlListAuthTests.swift new file mode 100644 index 000000000000..866ad1cc3510 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxCtlListAuthTests.swift @@ -0,0 +1,250 @@ +import Foundation +import Testing + +@testable import CmuxIrxTransport + +/// List-auth control-plane wire additions: the ack frame shape, the directory +/// overlay with and without the new fields, and the freshness stamps. +@Suite("control plane list-auth wire") +struct IrxCtlListAuthWireTests { + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + let iso = ISO8601DateFormatter() + decoder.dateDecodingStrategy = .custom { decoder in + let raw = try decoder.singleValueContainer().decode(String.self) + guard let date = iso.date(from: raw) else { + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "unparseable date: \(raw)")) + } + return date + } + return decoder + }() + + @Test func ackFrameShape() throws { + let data = try IrxControlPlaneClient.encodedAck( + rev: 42, appliedAt: Date(timeIntervalSince1970: 1_787_000_000)) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(object?["v"] as? Int == 1) + #expect(object?["type"] as? String == "ack") + #expect(object?["rev"] as? Int == 42) + let payload = object?["payload"] as? [String: Any] + // RFC3339 applied stamp, per the ack fixture. + let appliedAt = try #require(payload?["appliedAt"] as? String) + #expect(appliedAt.hasSuffix("Z")) + #expect(ISO8601DateFormatter().date(from: appliedAt) != nil) + } + + @Test func ackMatchesTheCheckedInFixtureShape() throws { + // Decode the schema agent's fixture with the generated model, then + // confirm our emitter round-trips through the same generated type. + let fixturesDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("schemas/control-plane/fixtures") + let fixture = try Data( + contentsOf: fixturesDirectory.appendingPathComponent("ack.json")) + let decoded = try Self.decoder.decode(CTLACK.self, from: fixture) + #expect(decoded.rev == 42) + let emitted = try IrxControlPlaneClient.encodedAck(rev: 42) + let reDecoded = try Self.decoder.decode(CTLACK.self, from: emitted) + #expect(reDecoded.rev == decoded.rev) + #expect(reDecoded.type == .ack) + } + + @Test func directoryOverlayDecodesNewFields() throws { + let json = Data( + """ + { + "v": 1, "type": "directory", "rev": 7, + "payload": { + "issuedAt": "2026-08-29T10:00:00Z", + "ttlSeconds": 86400, + "minimumSupportedVersion": {"mac": "1.2.0", "ios": "1.1.0"}, + "routeContractVersion": 1, + "relayFleet": ["https://usw1.relay.cmux.dev/"], + "grantVerificationKeys": [], + "bindings": [ + { + "bindingId": "b-1", + "clientNamespace": "com.cmuxterm.app", + "deviceId": "d-1", + "endpointId": "aabb", + "homeRelayUrl": "https://usw1.relay.cmux.dev/", + "instanceTag": "default", + "status": "seeded", + "revoked": false, + "appVersion": "1.2.3+456", + "releaseTrack": "stable", + "capabilities": ["cmux.irx.v2", "list-auth"], + "lastConfirmedAt": "2026-08-29T09:00:00Z" + } + ] + } + } + """.utf8) + let fact = try Self.decoder.decode(IrxCtlDirectoryFact.self, from: json) + #expect(fact.rev == 7) + #expect(fact.payload.ttlSeconds == 86_400) + #expect(fact.payload.issuedAt != nil) + #expect(fact.payload.minimumSupportedVersion?.mac == "1.2.0") + let entry = try #require(fact.payload.bindings.first) + #expect(entry.endpointID == "aabb") + #expect(entry.status == "seeded") + #expect(entry.revoked == false) + #expect(entry.capabilities == ["cmux.irx.v2", "list-auth"]) + + let snapshot = IrxDeviceListSnapshot( + fact: fact, receivedAtWall: Date(), receivedAtMonotonic: .now) + #expect(snapshot.rev == 7) + #expect(snapshot.entries["aabb"]?.status == "seeded") + #expect(snapshot.entries["aabb"]?.deviceID == "d-1") + #expect(snapshot.entries["aabb"]?.bindingID == "b-1") + } + + @Test func directoryOverlayToleratesAbsentNewFields() throws { + // A pre-list-auth server's directory: no issuedAt/ttl/status/revoked. + let json = Data( + """ + { + "v": 1, "type": "directory", "rev": 3, + "payload": { + "routeContractVersion": 1, + "relayFleet": [], + "grantVerificationKeys": [], + "bindings": [ + { + "bindingId": "b-1", + "clientNamespace": "com.cmuxterm.app", + "endpointId": "ccdd" + } + ] + } + } + """.utf8) + let fact = try Self.decoder.decode(IrxCtlDirectoryFact.self, from: json) + #expect(fact.payload.issuedAt == nil) + #expect(fact.payload.ttlSeconds == nil) + let wall = Date() + let snapshot = IrxDeviceListSnapshot( + fact: fact, receivedAtWall: wall, receivedAtMonotonic: .now) + // Defensive defaults: presence in the account directory authorizes, + // the receipt stands in for the missing stamp, TTL falls back to a day. + #expect(snapshot.entries["ccdd"]?.status == "active") + #expect(snapshot.entries["ccdd"]?.revoked == false) + #expect(snapshot.issuedAt == wall) + #expect(snapshot.ttlSeconds == IrxDeviceListSnapshot.defaultTTLSeconds) + } + + @Test func legacyDirectoryPayloadPreservesOldFrameFields() throws { + let json = Data( + """ + { + "v": 1, "type": "directory", "rev": 3, + "payload": { + "routeContractVersion": 1, + "relayFleet": ["https://usw1.relay.cmux.dev/"], + "grantVerificationKeys": [{"alg":"EdDSA","keyId":"k1","publicKey":"pk1"}], + "bindings": [{ + "bindingId": "b-1", + "clientNamespace": "com.cmuxterm.app", + "deviceId": "d-1", + "endpointId": "ccdd", + "homeRelayUrl": "https://usw1.relay.cmux.dev/" + }] + } + } + """.utf8) + let fact = try Self.decoder.decode(IrxCtlDirectoryFact.self, from: json) + let receivedAt = Date(timeIntervalSince1970: 1_787_000_000) + let payload = IrxControlPlaneClient.legacyDirectoryPayload( + from: fact, receivedAt: receivedAt) + #expect(payload.bindings.count == 1) + #expect(payload.bindings[0].bindingID == "b-1") + #expect(payload.bindings[0].clientNamespace == "com.cmuxterm.app") + #expect(payload.bindings[0].homeRelayURL == "https://usw1.relay.cmux.dev/") + #expect(payload.grantVerificationKeys.count == 1) + #expect(payload.relayFleet == ["https://usw1.relay.cmux.dev/"]) + #expect(payload.issuedAt == receivedAt) + #expect(payload.ttlSeconds == IrxDeviceListSnapshot.defaultTTLSeconds) + } + + @Test func currentFrameStampDecodesFromPayloadOrTopLevel() throws { + let inPayload = Data( + #"{"v":1,"type":"current","rev":9,"payload":{"issuedAt":"2026-08-29T10:00:00Z"}}"# + .utf8) + let payloadStamp = try Self.decoder.decode(IrxCtlFreshnessStamp.self, from: inPayload) + #expect(payloadStamp.rev == 9) + #expect(payloadStamp.issuedAt != nil) + + let topLevel = Data( + #"{"v":1,"type":"current","rev":10,"issuedAt":"2026-08-29T10:00:00Z"}"#.utf8) + let topStamp = try Self.decoder.decode(IrxCtlFreshnessStamp.self, from: topLevel) + #expect(topStamp.rev == 10) + #expect(topStamp.issuedAt != nil) + } + + @Test func snapshotCompleteWithoutStampDecodesStampless() throws { + let json = Data( + #"{"v":1,"type":"snapshot_complete","rev":11,"payload":{}}"#.utf8) + let stamp = try Self.decoder.decode(IrxCtlFreshnessStamp.self, from: json) + #expect(stamp.rev == 11) + #expect(stamp.issuedAt == nil) + } + + @Test func helloV2CarriesClientInfoAndOmitsItWhenAbsent() throws { + let bare = try JSONEncoder().encode( + IrxCtlHelloV2( + endpointID: "aabb", haveRev: nil, wantPasses: false, clientInfo: nil)) + let bareObject = + try JSONSerialization.jsonObject(with: bare) as? [String: Any] + let barePayload = bareObject?["payload"] as? [String: Any] + #expect(bareObject?["type"] as? String == "hello") + #expect(barePayload?["endpointId"] as? String == "aabb") + #expect(barePayload?["platform"] == nil) + + let info = IrxCtlClientInfo( + deviceID: "d-1", + platform: "mac", + appVersion: "1.2.3+456", + releaseTrack: "stable", + capabilities: ["cmux.irx.v2", "list-auth"] + ) + let full = try JSONEncoder().encode( + IrxCtlHelloV2( + endpointID: "aabb", haveRev: 5, wantPasses: true, clientInfo: info)) + let fullObject = + try JSONSerialization.jsonObject(with: full) as? [String: Any] + let payload = fullObject?["payload"] as? [String: Any] + #expect(payload?["haveRev"] as? Int == 5) + #expect(payload?["deviceId"] as? String == "d-1") + #expect(payload?["platform"] as? String == "mac") + #expect(payload?["releaseTrack"] as? String == "stable") + #expect((payload?["capabilities"] as? [String]) == ["cmux.irx.v2", "list-auth"]) + } + + @Test func helloAckOverlayToleratesOldAndNewShapes() throws { + let old = Data( + #"{"v":1,"type":"hello_ack","payload":{"sessionId":"s-1","resumedFromRev":null}}"# + .utf8) + let oldOverlay = try Self.decoder.decode(IrxCtlHelloAckOverlay.self, from: old) + #expect(oldOverlay.payload?.serverCapabilities == nil) + + let new = Data( + """ + {"v":1,"type":"hello_ack","payload":{ + "sessionId":"s-2","resumedFromRev":4, + "serverCapabilities":["list-auth"], + "minimumSupportedVersion":{"ios":"1.0.0"} + }} + """.utf8) + let newOverlay = try Self.decoder.decode(IrxCtlHelloAckOverlay.self, from: new) + #expect(newOverlay.payload?.serverCapabilities == ["list-auth"]) + #expect(newOverlay.payload?.minimumSupportedVersion?.ios == "1.0.0") + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxDeviceListTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxDeviceListTests.swift new file mode 100644 index 000000000000..74b0d8fbb2f5 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxDeviceListTests.swift @@ -0,0 +1,374 @@ +import CMUXMobileCore +import CmuxIrohTransport +import Foundation +import Testing + +@testable import CmuxIrxTransport + +/// In-memory secure store double for the device-list lease tests. +private actor InMemorySecureCredentialStore: CmxIrohSecureCredentialStoring { + private var records: [String: Data] = [:] + + func read(account: String) async throws -> Data? { + records[account] + } + + func write( + _ data: Data, + account: String, + accessibility _: CmxIrohSecureCredentialAccessibility + ) async throws { + records[account] = data + } + + func delete(account: String) async throws { + records[account] = nil + } + + func deleteAll() async throws { + records.removeAll() + } + + var recordCount: Int { records.count } +} + +private func testJournal(_ category: String) -> IrxJournal { + IrxJournal(subsystem: "dev.cmux.tests", category: category) +} + +private func makeSnapshot( + entries: [String: IrxDeviceListEntry], + rev: Int = 3, + ttlSeconds: Int = 86_400, + receivedAtWall: Date = Date(), + receivedAtMonotonic: ContinuousClock.Instant = .now +) -> IrxDeviceListSnapshot { + IrxDeviceListSnapshot( + entries: entries, + rev: rev, + issuedAt: receivedAtWall, + ttlSeconds: ttlSeconds, + receivedAtWall: receivedAtWall, + receivedAtMonotonic: receivedAtMonotonic + ) +} + +@Suite("device list snapshot freshness") +struct IrxDeviceListSnapshotTests { + @Test func freshInsideTTLStaleAfter() { + let base = ContinuousClock.now + let snapshot = makeSnapshot( + entries: [:], ttlSeconds: 100, receivedAtMonotonic: base) + #expect(snapshot.isFresh(now: base)) + #expect(snapshot.isFresh(now: base.advanced(by: .seconds(99)))) + #expect(!snapshot.isFresh(now: base.advanced(by: .seconds(100)))) + #expect(!snapshot.isFresh(now: base.advanced(by: .seconds(101)))) + } + + @Test func monotonicRegressionIsStale() { + // A "now" EARLIER than receipt can only mean a broken anchor; + // fail closed instead of treating it as maximally fresh. + let base = ContinuousClock.now + let snapshot = makeSnapshot( + entries: [:], ttlSeconds: 100, receivedAtMonotonic: base) + #expect(!snapshot.isFresh(now: base.advanced(by: .seconds(-1)))) + } + + @Test func restampOnlyMovesForward() { + let base = ContinuousClock.now + let issuedAt = Date(timeIntervalSince1970: 100) + let snapshot = makeSnapshot( + entries: [:], rev: 5, ttlSeconds: 100, + receivedAtWall: issuedAt, receivedAtMonotonic: base) + #expect(snapshot.restamped( + rev: 4, issuedAt: issuedAt.addingTimeInterval(1), + receivedAtWall: issuedAt.addingTimeInterval(1), + receivedAtMonotonic: base) == nil) + #expect(snapshot.restamped( + rev: 6, issuedAt: issuedAt.addingTimeInterval(1), + receivedAtWall: issuedAt.addingTimeInterval(1), + receivedAtMonotonic: base) == nil) + #expect(snapshot.restamped( + rev: 5, issuedAt: issuedAt, receivedAtWall: issuedAt, + receivedAtMonotonic: base) == nil) + #expect(snapshot.restamped( + rev: 5, issuedAt: issuedAt.addingTimeInterval(-1), + receivedAtWall: issuedAt.addingTimeInterval(-1), + receivedAtMonotonic: base) == nil) + let stamped = snapshot.restamped( + rev: 5, issuedAt: issuedAt.addingTimeInterval(1), + receivedAtWall: issuedAt.addingTimeInterval(1), + receivedAtMonotonic: base.advanced(by: .seconds(50))) + #expect(stamped?.rev == 5) + #expect(stamped?.isFresh(now: base.advanced(by: .seconds(120))) == true) + } +} + +@Suite("device list store persistence") +struct IrxDeviceListStoreTests { + private func entry(revoked: Bool = false, status: String = "active") -> IrxDeviceListEntry { + IrxDeviceListEntry(deviceID: "d-1", status: status, revoked: revoked) + } + + @Test func persistedLeaseRoundTripsFresh() async { + let secureStore = InMemorySecureCredentialStore() + let wall = Date() + let store = IrxDeviceListStore( + secureStore: secureStore, + accountID: "acct-1", + backendHost: "broker.example", + journal: testJournal("device-list-roundtrip"), + wallNow: { wall.addingTimeInterval(60) }, + monotonicNow: { .now } + ) + await store.persist( + makeSnapshot(entries: ["ep1": entry()], rev: 9, receivedAtWall: wall)) + let loaded = await store.loadPersisted() + #expect(loaded?.rev == 9) + #expect(loaded?.entries["ep1"]?.deviceID == "d-1") + // 60s of wall elapsed out of a day-long TTL: still fresh. + #expect(loaded?.isFresh(now: .now) == true) + } + + @Test func persistedLeaseExpiresByWallElapsed() async { + let secureStore = InMemorySecureCredentialStore() + let wall = Date() + let store = IrxDeviceListStore( + secureStore: secureStore, + accountID: "acct-1", + backendHost: "broker.example", + journal: testJournal("device-list-expiry"), + wallNow: { wall.addingTimeInterval(90_000) }, // > 86_400 + monotonicNow: { .now } + ) + await store.persist( + makeSnapshot(entries: ["ep1": entry()], receivedAtWall: wall)) + let loaded = await store.loadPersisted() + // The stale lease is RETURNED (so callers can fail closed) but is + // not fresh. + #expect(loaded != nil) + #expect(loaded?.isFresh(now: .now) == false) + } + + @Test func wallClockRollbackFailsClosed() async { + let secureStore = InMemorySecureCredentialStore() + let wall = Date() + let store = IrxDeviceListStore( + secureStore: secureStore, + accountID: "acct-1", + backendHost: "broker.example", + journal: testJournal("device-list-rollback"), + wallNow: { wall.addingTimeInterval(-3_600) }, // clock ran backward + monotonicNow: { .now } + ) + await store.persist( + makeSnapshot(entries: ["ep1": entry()], receivedAtWall: wall)) + let loaded = await store.loadPersisted() + #expect(loaded != nil) + #expect(loaded?.isFresh(now: .now) == false) + } + + @Test func clearRemovesTheLease() async { + let secureStore = InMemorySecureCredentialStore() + let store = IrxDeviceListStore( + secureStore: secureStore, + accountID: "acct-1", + backendHost: "broker.example", + journal: testJournal("device-list-clear") + ) + await store.persist(makeSnapshot(entries: ["ep1": entry()])) + await store.clear() + #expect(await store.loadPersisted() == nil) + #expect(await secureStore.recordCount == 0) + } + + @Test func scopeIsPerAccountAndBackend() { + let a = IrxDeviceListStore.storageAccount( + accountID: "acct-1", backendHost: "broker.example") + let b = IrxDeviceListStore.storageAccount( + accountID: "acct-2", backendHost: "broker.example") + let c = IrxDeviceListStore.storageAccount( + accountID: "acct-1", backendHost: "staging.example") + #expect(a != b) + #expect(a != c) + } + + @Test func scopeEncodingIsInjectiveAcrossDelimiterLikeValues() { + let first = IrxDeviceListStore.storageAccount( + accountID: "a-b", backendHost: "c") + let second = IrxDeviceListStore.storageAccount( + accountID: "a", backendHost: "b-c") + #expect(first != second) + } +} + +@Suite("list judge") +struct IrxListJudgeTests { + private let endpoint = String(repeating: "ab", count: 32) + + private func deniedCode( + _ judgment: IrxGrantJudgment, + grant: String? = nil, + endpoint: String + ) -> IrxCloseCode? { + do { + _ = try judgment(grant, endpoint) + return nil + } catch let denial as IrxAdmissionDenied { + return denial.code + } catch { + return nil + } + } + + @Test func allowsFreshListedUnrevokedPeer() throws { + let box = IrxDeviceListCurrent() + box.replace( + makeSnapshot(entries: [ + endpoint: IrxDeviceListEntry( + deviceID: "d-9", status: "seeded", revoked: false, + bindingID: "b-9", tag: "default") + ])) + let judge = IrxListJudge(current: box, journal: testJournal("judge-allow")) + // The grant is ignored entirely: nil and non-nil both admit. + let peer = try judge.judgment()(nil, endpoint) + #expect(peer.deviceID == "d-9") + #expect(peer.bindingID == "b-9") + #expect(peer.endpointIDHex == endpoint) + let withGrant = try judge.judgment()("some-legacy-grant", endpoint) + #expect(withGrant.deviceID == "d-9") + } + + @Test func deniesUnknownEndpoint() { + let box = IrxDeviceListCurrent() + box.replace( + makeSnapshot(entries: [ + endpoint: IrxDeviceListEntry(status: "active", revoked: false) + ])) + let judge = IrxListJudge(current: box, journal: testJournal("judge-unknown")) + #expect( + deniedCode(judge.judgment(), endpoint: String(repeating: "cd", count: 32)) + == .invalidGrant) + } + + @Test func deniesRevokedEntry() { + let box = IrxDeviceListCurrent() + box.replace( + makeSnapshot(entries: [ + endpoint: IrxDeviceListEntry(status: "active", revoked: true) + ])) + let judge = IrxListJudge(current: box, journal: testJournal("judge-revoked")) + #expect(deniedCode(judge.judgment(), endpoint: endpoint) == .revoked) + } + + @Test func deniesAbsentSnapshot() { + let box = IrxDeviceListCurrent() + let judge = IrxListJudge(current: box, journal: testJournal("judge-absent")) + #expect(deniedCode(judge.judgment(), endpoint: endpoint) == .invalidGrant) + } + + @Test func deniesStaleLease() { + let base = ContinuousClock.now + let box = IrxDeviceListCurrent() + box.replace( + makeSnapshot( + entries: [ + endpoint: IrxDeviceListEntry(status: "active", revoked: false) + ], + ttlSeconds: 100, + receivedAtMonotonic: base + )) + let judge = IrxListJudge( + current: box, + journal: testJournal("judge-stale"), + now: { base.advanced(by: .seconds(101)) } + ) + #expect(deniedCode(judge.judgment(), endpoint: endpoint) == .invalidGrant) + } + + @Test func deniesAfterWallClockRollbackRehydration() async { + // Full path: persist, roll the wall clock back, load, judge. + let secureStore = InMemorySecureCredentialStore() + let wall = Date() + let store = IrxDeviceListStore( + secureStore: secureStore, + accountID: "acct-1", + backendHost: "broker.example", + journal: testJournal("judge-rollback"), + wallNow: { wall.addingTimeInterval(-60) }, + monotonicNow: { .now } + ) + await store.persist( + makeSnapshot( + entries: [ + endpoint: IrxDeviceListEntry(status: "active", revoked: false) + ], + receivedAtWall: wall + )) + let box = IrxDeviceListCurrent() + box.replace(await store.loadPersisted()) + let judge = IrxListJudge(current: box, journal: testJournal("judge-rollback2")) + #expect(deniedCode(judge.judgment(), endpoint: endpoint) == .invalidGrant) + } + + @Test func clearFailsClosedImmediately() throws { + let box = IrxDeviceListCurrent() + box.replace( + makeSnapshot(entries: [ + endpoint: IrxDeviceListEntry(status: "active", revoked: false) + ])) + let judge = IrxListJudge(current: box, journal: testJournal("judge-clear")) + _ = try judge.judgment()(nil, endpoint) + box.clear() + #expect(deniedCode(judge.judgment(), endpoint: endpoint) == .invalidGrant) + } +} + +@Suite("grantless hello wire compatibility") +struct IrxHelloGrantlessTests { + @Test func legacyGrantBearingHelloStillParses() throws { + let legacy = Data( + #"{"v":1,"proto":"cmux/irx/1","grant":"jws-material"}"#.utf8) + let hello = try JSONDecoder().decode(IrxHello.self, from: legacy) + #expect(hello.grant == "jws-material") + } + + @Test func grantlessHelloRoundTrips() throws { + let encoded = try JSONEncoder().encode(IrxHello()) + let text = String(decoding: encoded, as: UTF8.self) + #expect(!text.contains("grant")) + let hello = try JSONDecoder().decode(IrxHello.self, from: encoded) + #expect(hello.grant == nil) + #expect(hello.proto == IrxProtocol.alpn) + } + + @Test func legacyGrantJudgeDeniesGrantlessHello() { + let judge = IrxGrantJudge( + acceptor: Self.dummyAcceptor(), + trustProvider: { nil } + ) + do { + _ = try judge.judgment()(nil, String(repeating: "ab", count: 32)) + Issue.record("grantless hello must not pass the legacy grant judge") + } catch let denial as IrxAdmissionDenied { + #expect(denial.code == .invalidGrant) + } catch { + Issue.record("unexpected error: \(error)") + } + } + + private static func dummyAcceptor() -> CmxIrohGrantPeer { + // A structurally valid acceptor tuple; the grantless guard fires + // before any of it is inspected. + let endpointID = try! CmxIrohPeerIdentity( + endpointID: String(repeating: "ab", count: 32)) + return CmxIrohGrantPeer( + bindingID: "b-test", + deviceID: "d-test", + tag: "test", + platform: .mac, + endpointID: endpointID, + identityGeneration: 1 + ) + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxJSONCacheMigrationTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxJSONCacheMigrationTests.swift new file mode 100644 index 000000000000..bf03577bdf48 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxJSONCacheMigrationTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing + +@testable import CmuxIrxTransport + +/// In-memory primary standing in for the Release keychain cache (SPM tests +/// have no keychain entitlement; the migration logic is backend-agnostic). +private final class InMemoryJSONCache: IrxJSONCache, @unchecked Sendable { + private let lock = NSLock() + private var value: Value? + private(set) var saveCount = 0 + + func load() -> Value? { + lock.lock() + defer { lock.unlock() } + return value + } + + @discardableResult + func save(_ newValue: Value) -> Bool { + lock.lock() + value = newValue + saveCount += 1 + lock.unlock() + return true + } + + func clear() { + lock.lock() + value = nil + lock.unlock() + } +} + +@Suite("broker cache keychain migration") +struct IrxJSONCacheMigrationTests { + private func temporaryFile() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("irx-cache-migration-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("binding.json") + } + + @Test func firstLoadMigratesLegacyFileAndDeletesIt() throws { + let fileURL = temporaryFile() + let legacy = IrxDiskCache<[String: Int]>(fileURL: fileURL) + legacy.save(["rev": 4]) + let primary = InMemoryJSONCache<[String: Int]>() + let migrating = IrxMigratingJSONCache(primary: primary, legacy: legacy) + + #expect(migrating.load() == ["rev": 4]) + // Migration is one-way: the value now lives in the primary and the + // legacy file is gone. + #expect(primary.load() == ["rev": 4]) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test func migrationIsIdempotentAcrossRepeatedLoads() throws { + let fileURL = temporaryFile() + let legacy = IrxDiskCache<[String: Int]>(fileURL: fileURL) + legacy.save(["rev": 4]) + let primary = InMemoryJSONCache<[String: Int]>() + let migrating = IrxMigratingJSONCache(primary: primary, legacy: legacy) + + #expect(migrating.load() == ["rev": 4]) + #expect(migrating.load() == ["rev": 4]) + #expect(migrating.load() == ["rev": 4]) + #expect(primary.saveCount == 1) + } + + @Test func primaryWinsOverAResurrectedLegacyFile() throws { + let fileURL = temporaryFile() + let legacy = IrxDiskCache<[String: Int]>(fileURL: fileURL) + let primary = InMemoryJSONCache<[String: Int]>() + primary.save(["rev": 9]) + let migrating = IrxMigratingJSONCache(primary: primary, legacy: legacy) + // A stale file written later (old build, restored backup) is ignored + // while the primary holds a value. + legacy.save(["rev": 1]) + #expect(migrating.load() == ["rev": 9]) + } + + @Test func saveWritesPrimaryAndDropsLegacy() throws { + let fileURL = temporaryFile() + let legacy = IrxDiskCache<[String: Int]>(fileURL: fileURL) + legacy.save(["rev": 1]) + let primary = InMemoryJSONCache<[String: Int]>() + let migrating = IrxMigratingJSONCache(primary: primary, legacy: legacy) + + migrating.save(["rev": 2]) + #expect(primary.load() == ["rev": 2]) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + #expect(migrating.load() == ["rev": 2]) + } + + @Test func emptyBothSidesLoadsNil() throws { + let migrating = IrxMigratingJSONCache( + primary: InMemoryJSONCache<[String: Int]>(), + legacy: IrxDiskCache<[String: Int]>(fileURL: temporaryFile()) + ) + #expect(migrating.load() == nil) + } + + @Test func failedPrimaryWriteKeepsLegacyFile() throws { + let fileURL = temporaryFile() + let legacy = IrxDiskCache<[String: Int]>(fileURL: fileURL) + legacy.save(["rev": 4]) + let primary = FailingJSONCache<[String: Int]>() + let migrating = IrxMigratingJSONCache(primary: primary, legacy: legacy) + + #expect(migrating.load() == ["rev": 4]) + #expect(legacy.load() == ["rev": 4]) + } + + @Test func clearRemovesBothSides() throws { + let fileURL = temporaryFile() + let legacy = IrxDiskCache<[String: Int]>(fileURL: fileURL) + legacy.save(["rev": 1]) + let primary = InMemoryJSONCache<[String: Int]>() + primary.save(["rev": 2]) + let migrating = IrxMigratingJSONCache(primary: primary, legacy: legacy) + migrating.clear() + #expect(migrating.load() == nil) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } +} + +private struct FailingJSONCache: IrxJSONCache { + func load() -> Value? { nil } + @discardableResult + func save(_ value: Value) -> Bool { false } + func clear() {} +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift index b700957d41fc..1bc8b652932e 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift @@ -94,4 +94,18 @@ public actor RPCStackTokenGate { } abandoned[id] = nil } + + /// Clears the timed-out suppression window after an event that invalidates + /// it (a fresh interactive sign-in, an explicit pairing attempt): the stuck + /// provider was almost certainly waiting on auth state that has since + /// resolved, so the next request should try a fresh acquisition instead of + /// failing fast for the remainder of the window. The stuck task is + /// abandoned (already cancelled by `timeoutWaiter`; its completion watcher + /// reaps it), and an in-flight acquisition that has NOT timed out is left + /// untouched. + public func resetTimedOutSuppression() { + guard let existing = current, existing.timedOutUntil != nil else { return } + abandoned[existing.id] = existing.task + current = nil + } } diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift index e6dfaa80cf5c..35c3fe546ab9 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift @@ -183,6 +183,44 @@ import Testing await tokenProvider.release() } + @Test func resetTimedOutSuppressionAllowsAFreshProviderImmediately() async throws { + let tokenProvider = CancellationIgnoringTokenProvider() + // A long window: without the explicit reset, every request inside it + // fails fast without starting a provider. + let gate = RPCStackTokenGate(timedOutResetNanoseconds: 3_600_000_000_000) + + do { + _ = try await gate.token(timeoutNanoseconds: 1) { + try await tokenProvider.token() + } + Issue.record("Expected first token request to time out") + } catch MobileShellConnectionError.requestTimedOut { + } catch { + Issue.record("Expected requestTimedOut, got \(error)") + } + #expect(await tokenProvider.startCount == 1) + + // Poisoned: fails fast, no new provider. + do { + _ = try await gate.token(timeoutNanoseconds: 1) { + try await tokenProvider.token() + } + Issue.record("Expected suppressed token request to fail fast") + } catch MobileShellConnectionError.requestTimedOut { + } catch { + Issue.record("Expected requestTimedOut, got \(error)") + } + #expect(await tokenProvider.startCount == 1) + + // A completed sign-in (or explicit pairing attempt) clears the window: + // the very next request starts a fresh provider. + await gate.resetTimedOutSuppression() + await tokenProvider.release() + let token = try await waitForReleasedToken(gate: gate, tokenProvider: tokenProvider) + #expect(token == "released-token") + #expect(await tokenProvider.startCount == 2) + } + @Test func timedOutStackTokenGateRetriesAfterBoundedReset() async throws { let tokenProvider = CancellationIgnoringTokenProvider() let gate = RPCStackTokenGate(timedOutResetNanoseconds: 0) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index b841c7943fba..e989723efae5 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -4818,6 +4818,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let attemptID = beginPairingAttempt(method: "qr") + // A pairing attempt begins with fresh interactive auth (a QR scan, or + // the injected dev attach that fires only after sign-in completes), so + // a token-gate suppression window left by a fetch that raced that + // sign-in is stale input. Without this, pairing fails fast + // (`requestTimedOut` within milliseconds) for up to 30s after launch + // and never persists the ticket's routes — the exact race the dev + // launcher hits on every forced fresh sign-in. + await stackTokenGate.resetTimedOutSuppression() + await stackTokenForceRefreshGate.resetTimedOutSuppression() + // Offline preflight: fail fast instead of stacking per-route connect // timeouts into the opaque ~60s wait. Skipped only when no route is // dialable so `connect()` classifies that as `no_supported_route`. diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift new file mode 100644 index 000000000000..219c7714209d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift @@ -0,0 +1,113 @@ +public import Foundation +public import Observation + +/// The phone's view of the account device list (the list-auth admission +/// authority), projected for UI. +/// +/// Written by the irx composition on every applied directory fact and on +/// sign-out; read by the Computers surfaces to warn about Macs the new +/// connection system has not verified yet (directory `status == "seeded"`: +/// a binding never confirmed by its own control-plane hello, i.e. a Mac +/// still running a pre-list-auth cmux). +/// +/// A process-wide shared instance is the seam here because the writer lives +/// in `cmuxFeature` (the transport composition) and the readers live in +/// `CmuxMobileShellUI`, packages with no injection path between them today. +@MainActor +@Observable +public final class MobileMacListAuthState { + public struct Entry: Equatable, Sendable { + /// Directory lifecycle state (active/seeded/stale/...), verbatim. + public var status: String + public var revoked: Bool + /// Whether the lease the entry came from is currently fresh. + public var isFresh: Bool + /// Version reported by the Mac's control-plane hello, including an + /// optional `+build` suffix. + public var appVersion: String? + /// Server-advertised minimum Mac version for this account. + public var minimumSupportedVersion: String? + + public init( + status: String, + revoked: Bool, + isFresh: Bool, + appVersion: String? = nil, + minimumSupportedVersion: String? = nil + ) { + self.status = status + self.revoked = revoked + self.isFresh = isFresh + self.appVersion = appVersion + self.minimumSupportedVersion = minimumSupportedVersion + } + + /// True only when both versions are known and the Mac is below the + /// server floor. Malformed or channel-only values stay informational. + public var isOutdated: Bool { + guard let appVersion, let minimumSupportedVersion, + let installed = Self.numericVersion(appVersion), + let required = Self.numericVersion(minimumSupportedVersion) + else { return false } + return installed.lexicographicallyPrecedes(required) + } + + private static func numericVersion(_ raw: String) -> [Int]? { + let core = raw.split(separator: "+", maxSplits: 1).first.map(String.init) ?? raw + let parts = core.split(separator: ".", omittingEmptySubsequences: false) + guard !parts.isEmpty, + parts.allSatisfy({ !$0.isEmpty && Int($0) != nil }) else { return nil } + var values = parts.map { Int($0)! } + while values.count < 3 { values.append(0) } + return values + } + } + + public static let shared = MobileMacListAuthState() + + /// Entries keyed by the Mac's endpoint ID hex (TLS identity). + public private(set) var entriesByEndpointID: [String: Entry] = [:] + /// The same entries keyed by the Mac's durable device id, the key the + /// Computers rows carry. + public private(set) var entriesByDeviceID: [String: Entry] = [:] + /// Whether ANY device list has been received or restored this session. + /// False on a fresh install pre-hello (the dial bootstrap window). + public private(set) var hasSnapshot = false + /// Account-level minimum Mac version from the latest directory fact. + public private(set) var minimumSupportedMacVersion: String? + + public init() {} + + public func replace( + entriesByEndpointID: [String: Entry], + entriesByDeviceID: [String: Entry], + minimumSupportedMacVersion: String? = nil + ) { + self.entriesByEndpointID = entriesByEndpointID + self.entriesByDeviceID = entriesByDeviceID + self.minimumSupportedMacVersion = minimumSupportedMacVersion + hasSnapshot = true + } + + public func clear() { + entriesByEndpointID = [:] + entriesByDeviceID = [:] + minimumSupportedMacVersion = nil + hasSnapshot = false + } + + public func entry(endpointIDHex: String) -> Entry? { + entriesByEndpointID[endpointIDHex] + } + + public func entry(deviceID: String) -> Entry? { + entriesByDeviceID[deviceID] + } + + /// The Computers-row warning predicate: the Mac exists in the directory + /// but was seeded by the account overlay and has never confirmed itself + /// on the new connection system. + public func isSeeded(deviceID: String) -> Bool { + entriesByDeviceID[deviceID]?.status == "seeded" + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileMacListAuthStateTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileMacListAuthStateTests.swift new file mode 100644 index 000000000000..5eb712a5bd9b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileMacListAuthStateTests.swift @@ -0,0 +1,47 @@ +import CmuxMobileShellModel +import Testing + +@Suite +struct MobileMacListAuthStateTests { + @Test + func comparesReportedVersionToServerFloor() { + let outdated = MobileMacListAuthState.Entry( + status: "active", + revoked: false, + isFresh: true, + appVersion: "0.64.19+123", + minimumSupportedVersion: "0.64.20" + ) + #expect(outdated.isOutdated) + + let current = MobileMacListAuthState.Entry( + status: "active", + revoked: false, + isFresh: true, + appVersion: "0.64.20+1", + minimumSupportedVersion: "0.64.20" + ) + #expect(!current.isOutdated) + } + + @Test + func unknownOrMalformedVersionsDoNotWarn() { + let unknown = MobileMacListAuthState.Entry( + status: "active", + revoked: false, + isFresh: true, + appVersion: nil, + minimumSupportedVersion: "0.64.20" + ) + #expect(!unknown.isOutdated) + + let malformed = MobileMacListAuthState.Entry( + status: "active", + revoked: false, + isFresh: true, + appVersion: "nightly", + minimumSupportedVersion: "0.64.20" + ) + #expect(!malformed.isOutdated) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 3993b3166331..2d1fd88ccc35 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -1205,6 +1205,11 @@ struct CMUXMobileRootView: View { let token = UUID() openURLTaskToken = token openURLTask = Task { @MainActor in + // An explicit pairing attempt supersedes parked timed-out auth + // phases: one launch-time Stack call hung on a dead pooled + // connection otherwise fast-fails this attempt (`timedOut` within + // milliseconds) for the damper's remaining 30s. + await authManager.supersedeTimedOutAuthPhases() let result = await store.connectPairingURLResult(rawURL) guard !Task.isCancelled, openURLTaskToken == token else { return } let failure: DiagnosticFailureKind? = switch result { @@ -1352,7 +1357,12 @@ struct CMUXMobileRootView: View { await dogfoodAttachPreparation.waitUntilReady() }, connect: { rawURL in - await store.connectPairingURLResult(rawURL) + // Same supersede as the open-URL pairing path: the injected + // attach fires seconds after the forced dev sign-in, exactly + // when a hung launch-time Stack call has the phase damper + // armed, and must not inherit that fast-fail. + await authManager.supersedeTimedOutAuthPhases() + return await store.connectPairingURLResult(rawURL) }, onCompletion: { completion in if completion.result == .needsUserApproval { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift index 0ebc7e282331..4e2fa681a97a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift @@ -4,6 +4,7 @@ import CmuxMobilePairedMac import CmuxMobileShell import CmuxMobileShellModel import CmuxMobileSupport +import Foundation import SwiftUI /// Comprehensive per-computer detail + debug sheet, pushed from the Computers @@ -121,6 +122,10 @@ struct MacComputerDetailView: View { } var body: some View { Form { + if let listAuthEntry, + listAuthEntry.status == "seeded" || listAuthEntry.isOutdated { + MacComputerCompatibilitySection(entry: listAuthEntry) + } connectionMethodSection appearanceSection connectionSection @@ -477,6 +482,10 @@ struct MacComputerDetailView: View { } } + private var listAuthEntry: MobileMacListAuthState.Entry? { + MobileMacListAuthState.shared.entry(deviceID: macDeviceID) + } + // MARK: - Connection configuration /// This Computer's own networking configuration: the connection method it @@ -1323,4 +1332,61 @@ struct MacComputerDetailView: View { } } +/// Persistent explanation for a Mac that has not confirmed the current +/// control plane or is below the server-advertised version floor. This is a +/// separate view so the detail form's other state does not share this section's +/// invalidation boundary. +private struct MacComputerCompatibilitySection: View { + let entry: MobileMacListAuthState.Entry + + var body: some View { + Section { + Label { + VStack(alignment: .leading, spacing: 6) { + Text(warningTitle) + .font(.headline) + Text(warningMessage) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + .accessibilityIdentifier("MobileComputerCompatibilityWarning") + } + } + + private var warningTitle: String { + if entry.isOutdated { + return L10n.string( + "computers.version.outdated.title", + defaultValue: "Mac update required" + ) + } + return L10n.string( + "computers.listauth.unverified.title", + defaultValue: "Not verified on the new connection system yet" + ) + } + + private var warningMessage: String { + if entry.isOutdated, + let installed = entry.appVersion, + let required = entry.minimumSupportedVersion { + let updateMessage = L10n.string( + "computers.version.outdated.detail", + defaultValue: "This Mac is running %@. Update it to %@ or later." + ) + return String(format: updateMessage, installed, required) + } + return L10n.string( + "computers.listauth.unverified.detail", + defaultValue: + "It may be running an older cmux version. Update the Mac, or if it's already updated, open cmux on it once to verify." + ) + } +} + #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerRow.swift index 73b839414141..7cf01bcd5d8e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerRow.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerRow.swift @@ -36,6 +36,8 @@ struct MacComputerRow: View { /// button, so the row does not flash a dimmed state. var isConnecting: Bool = false + @State private var showListAuthInfo = false + var body: some View { HStack(spacing: 8) { rowContainer @@ -97,6 +99,9 @@ struct MacComputerRow: View { if let buildLabel = computer.buildLabel { ComputerBuildBadge(label: buildLabel) } + if showsListAuthWarning { + listAuthWarningButton + } } Text(connectionLine) .font(.caption) @@ -156,6 +161,86 @@ struct MacComputerRow: View { } } + /// Whether the account device list has a compatibility warning for this + /// Mac, either because it has not confirmed list-auth yet or because its + /// reported version is below the server's current minimum. + private var showsListAuthWarning: Bool { + guard let entry = MobileMacListAuthState.shared.entry(deviceID: computer.deviceId) + else { return false } + return entry.status == "seeded" || entry.isOutdated + } + + /// Seeded rows carry a compact warning triangle beside the name; the + /// explanation lives in a popover so the row itself stays one avatar tall. + /// Borderless keeps the tap target separate from the row's navigation. + private var listAuthWarningButton: some View { + Button { + showListAuthInfo = true + } label: { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + .buttonStyle(.borderless) + .accessibilityLabel(listAuthWarningTitle) + .accessibilityIdentifier( + "MobileComputerListAuthWarning-\(computer.connectionRef.automationID)" + ) + .popover(isPresented: $showListAuthInfo, arrowEdge: .top) { + VStack(alignment: .leading, spacing: 8) { + Label { + Text(listAuthWarningTitle) + .font(.subheadline.weight(.semibold)) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + Text(listAuthWarningMessage) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .frame(idealWidth: 300, maxWidth: 340) + .presentationCompactAdaptation(.popover) + } + } + + private var listAuthWarningTitle: String { + if MobileMacListAuthState.shared.entry(deviceID: computer.deviceId)?.isOutdated == true { + return L10n.string( + "computers.version.outdated.title", + defaultValue: "Mac update required" + ) + } + return L10n.string( + "computers.listauth.unverified.title", + defaultValue: "Not verified on the new connection system yet" + ) + } + + private var listAuthWarningMessage: String { + guard let entry = MobileMacListAuthState.shared.entry(deviceID: computer.deviceId), + entry.isOutdated, + let installed = entry.appVersion, + let required = entry.minimumSupportedVersion + else { + return L10n.string( + "computers.listauth.unverified.detail", + defaultValue: + "It may be running an older cmux version. Update the Mac, or if it's already updated, open cmux on it once to verify." + ) + } + return String( + format: L10n.string( + "computers.version.outdated.detail", + defaultValue: "This Mac is running %@. Update it to %@ or later." + ), + installed, + required + ) + } + private var dotColor: Color { switch style { case .computers: diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f88cbbaa678a..bdd5ac50f421 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -188197,6 +188197,23 @@ } } }, + "settings.networking.irx.notConfigurable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This setting is not configurable with the current transport." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この設定は現在のトランスポートでは変更できません。" + } + } + } + }, "settings.networking.neverUseRelays": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/AgentHibernationController.swift b/Sources/App/AgentHibernationController.swift index 73de07cad8d9..cee38784d5fe 100644 --- a/Sources/App/AgentHibernationController.swift +++ b/Sources/App/AgentHibernationController.swift @@ -22,7 +22,6 @@ struct AgentHibernationRecord { let processIDs: Set let processIdentities: [Int: AgentPIDProcessIdentity] let processLiveness: RestorableAgentProcessLiveness - init( key: AgentHibernationPanelKey, workspace: Workspace, diff --git a/Sources/Auth/AuthEnvironment.swift b/Sources/Auth/AuthEnvironment.swift index 53f88ebdd587..39723b622a62 100644 --- a/Sources/Auth/AuthEnvironment.swift +++ b/Sources/Auth/AuthEnvironment.swift @@ -262,6 +262,43 @@ enum AuthEnvironment { #endif } + /// Base URL for the team device registry (`POST /api/devices` route + /// publication). + /// + /// The registry carries this Mac's Iroh route to phones, and dev iPhones + /// read it from the shared staging deployment (the device rig's default + /// origin), so a Debug Mac must publish there too. The tag rig BAKES a + /// localhost `CMUX_VM_API_BASE_URL` into every Debug bundle, so routing + /// this lane through `vmAPIBaseURL` publishes into a tag-local server no + /// phone ever reads, and a paired phone whose Mac changed endpoint + /// identity keeps dialing the dead endpoint forever. Mirrors + /// `pushAPIBaseURL`; Release keeps the production VM-API origin. + static var deviceRegistryAPIBaseURL: URL { + let environment = ProcessInfo.processInfo.environment + if let overridden = environment["CMUX_DEVICE_REGISTRY_API_BASE_URL"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !overridden.isEmpty, + let url = URL(string: overridden) { + return canonicalizedLoopbackURL(url) + } + #if DEBUG + if let override = devOverride(key: "CMUX_DEVICE_REGISTRY_API_BASE_URL"), + let url = URL(string: override) { + return canonicalizedLoopbackURL(url) + } + return resolvedDeviceRegistryAPIBaseURL(isDebugBuild: true, vmAPIBaseURL: vmAPIBaseURL) + #else + return resolvedDeviceRegistryAPIBaseURL(isDebugBuild: false, vmAPIBaseURL: vmAPIBaseURL) + #endif + } + + static func resolvedDeviceRegistryAPIBaseURL( + isDebugBuild: Bool, + vmAPIBaseURL: URL + ) -> URL { + isDebugBuild ? URL(string: "https://cmux-staging.vercel.app")! : vmAPIBaseURL + } + /// Authenticated route broker shared by matching tagged Mac and iOS builds. /// /// General tagged APIs remain on their isolated localhost origin. Iroh uses diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 2e7a0c9b0f39..8e39eaed7ff4 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -109,7 +109,9 @@ final class DeviceRegistryClient { let registration = Registration(teamID: teamID, tag: tag, routes: routes) guard Self.shouldReRegister(previous: lastRegistration, current: registration) else { return } - guard var comps = URLComponents(url: AuthEnvironment.vmAPIBaseURL, resolvingAgainstBaseURL: false) else { + guard var comps = URLComponents( + url: AuthEnvironment.deviceRegistryAPIBaseURL, resolvingAgainstBaseURL: false + ) else { return } comps.path = (comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path) + "/api/devices" @@ -152,7 +154,10 @@ final class DeviceRegistryClient { } } } catch { - // best-effort; registry must never disrupt the Mac. + // Best-effort; the registry must never disrupt the Mac. Still log: + // a silently unreachable registry strands every paired phone on + // stale routes with nothing to diagnose from. + NSLog("cmux.deviceRegistry register unreachable: %@", String(describing: error)) } } diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index 01ff7e5cfc4f..dd5252976bab 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -516,7 +516,13 @@ final class HostSettingsActions: SettingsHostActions { } func irohSettingsController() -> (any CmxIrohSettingsControlling)? { - MobileHostIrohRuntime.shared + // Exactly one runtime owns the transport slot (gated in + // MobileHostService.configure); Settings must read the same one, or + // the Networking section reports the dormant stack's stale state. + if MobileHostIrxRuntime.isEnabled { + return MobileHostIrxRuntime.shared + } + return MobileHostIrohRuntime.shared } /// Maps the host's ``MobileHostServiceStatus`` into the settings package's diff --git a/Sources/Mobile/MobileAttachTicketStore.swift b/Sources/Mobile/MobileAttachTicketStore.swift index 2e9a57c37c81..80cd85c2f232 100644 --- a/Sources/Mobile/MobileAttachTicketStore.swift +++ b/Sources/Mobile/MobileAttachTicketStore.swift @@ -246,7 +246,8 @@ final class MobileAttachTicketStore { ), let decoded = try? CmxPairingQRCode().decode(components), decoded.routes.count == ticket.routes.count, - decoded.routes.first?.endpoint == ticket.routes.first?.endpoint else { + decoded.routes.first?.endpoint == ticket.routes.first?.endpoint, + decoded.macDeviceID == ticket.macDeviceID else { throw MobileAttachTicketStoreError.invalidAttachURL } return url diff --git a/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift b/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift new file mode 100644 index 000000000000..2469010327d4 --- /dev/null +++ b/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift @@ -0,0 +1,310 @@ +import CMUXMobileCore +import Foundation + +/// Thrown for Settings mutations the irx runtime does not support yet +/// (managed/custom relay selection, custom relay definitions, path +/// preferences beyond the force-relay debug flag). Explicit so the UI shows +/// its save-failed alert instead of pretending the change persisted. +struct MobileHostIrxSettingsUnsupportedError: LocalizedError { + var errorDescription: String? { + String( + localized: "settings.networking.irx.notConfigurable", + defaultValue: "This setting is not configurable with the current transport." + ) + } +} + +/// Settings Networking backend for the irx runtime. Read paths project the +/// real irx state (activation phase, endpoint relay link, cached broker +/// trust/credentials); mutations irx cannot honor throw +/// ``MobileHostIrxSettingsUnsupportedError`` rather than no-op. +@MainActor +extension MobileHostIrxRuntime: CmxIrohSettingsControlling { + func irohSettingsSnapshot() async -> CmxIrohSettingsSnapshot { + let phase = settingsPhase + let hadLiveDiscovery = hadLiveDiscoveryThisRun + let broker = brokerService + let supervisor = endpointSupervisor + let trust = await broker?.cachedTrust() + let credentials = await broker?.cachedRelayCredentials() ?? [] + let endpointOnline = await supervisor?.isHealthy() ?? false + let homeRelayURL = await supervisor?.homeRelayURL() + return Self.settingsSnapshot( + phase: phase, + forceRelayOnly: Self.forceRelayOnly, + endpointOnline: endpointOnline, + homeRelayURL: homeRelayURL, + relayFleet: trust?.relayFleet ?? [], + hasTrustSnapshot: trust != nil, + hadLiveDiscovery: hadLiveDiscovery, + credentialExpiry: credentials.map(\.expiresAt).max() + ) + } + + func irohSettingsUpdates() -> AsyncStream { + let id = UUID() + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + irxSettingsContinuations[id] = continuation + ensureSettingsRefreshLoop() + Task { @MainActor [weak self] in + guard let self else { return } + continuation.yield(await self.irohSettingsSnapshot()) + } + continuation.onTermination = { @Sendable [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.irxSettingsContinuations.removeValue(forKey: id) + if self.irxSettingsContinuations.isEmpty { + self.irxSettingsRefreshTask?.cancel() + self.irxSettingsRefreshTask = nil + } + } + } + } + } + + func setIrohRelayPreference(_ preference: CmxIrohRelayPreferenceDraft) async throws { + // irx has no account relay preference: the usable relay set IS the + // signed server fleet. Automatic therefore already holds (idempotent + // success); any narrowing is unsupported and must fail loudly. + guard case .automatic = preference else { + throw MobileHostIrxSettingsUnsupportedError() + } + } + + func setIrohPathPreference(_ preference: CmxIrohPathPreference) async throws { + // The force-relay debug flag is fixed at activation; only requests + // that already match the active mode succeed (idempotent set). + switch preference { + case .automatic where !Self.forceRelayOnly: + return + case .relayOnly where Self.forceRelayOnly: + return + default: + throw MobileHostIrxSettingsUnsupportedError() + } + } + + func upsertIrohCustomRelay( + _ relay: CmxIrohCustomRelayDraft, + deviceSecret: String? + ) async throws { + throw MobileHostIrxSettingsUnsupportedError() + } + + func removeIrohCustomRelay(id: String) async throws { + throw MobileHostIrxSettingsUnsupportedError() + } + + func testIrohCustomRelay(id: String) async -> CmxIrohRelayTestResult { + // No custom relays exist under irx, so no definition is testable. + .incomplete + } + + func resetIrohSettingsToDefaults() async throws { + // irx persists no user-configurable networking state; defaults are + // already in effect, so reset genuinely leaves the requested state. + } + + func refreshIrohSettings() async { + guard let broker = brokerService else { + publishIrxSettingsUpdate() + return + } + // Force a live discovery so the fleet and policy source are current. + if (try? await broker.discover(maximumAge: 0)) != nil { + noteLiveDiscoverySucceeded() + } + publishIrxSettingsUpdate() + } + + func runIrohConnectionCheck() async -> CmxIrohConnectionCheckReport { + await refreshIrohSettings() + let snapshot = await irohSettingsSnapshot() + let diagnostics = await irohDiagnosticReport() + let endpointOnline = await endpointSupervisor?.isHealthy() ?? false + let relayReachability: CmxIrohConnectionCheckReport.RelayReachability + if settingsPhase == .idle { + relayReachability = .notConfigured + } else if endpointOnline { + // The endpoint's relay link is up; irx readiness IS relay + // reachability (v1 serves relay paths only). + relayReachability = .reachable + } else { + // Indeterminate (activating, rebinding, or failed): never send + // users to corporate IT off a probe that did not run. + relayReachability = .unavailable + } + return CmxIrohConnectionCheckReport( + role: .macHost, + snapshot: snapshot, + diagnostics: diagnostics, + relayReachability: relayReachability + ) + } + + /// Pushes a fresh snapshot to every live Settings subscriber. Cheap when + /// nobody is subscribed. + func publishIrxSettingsUpdate() { + guard !irxSettingsContinuations.isEmpty else { return } + Task { @MainActor [weak self] in + guard let self else { return } + let snapshot = await self.irohSettingsSnapshot() + for continuation in self.irxSettingsContinuations.values { + continuation.yield(snapshot) + } + } + } + + /// Periodic re-yield while (and only while) subscribers exist, so + /// credential-expiry drift and missed hooks self-heal without tight + /// timers. Cancelled by the last subscriber's termination. + private func ensureSettingsRefreshLoop() { + guard irxSettingsRefreshTask == nil else { return } + irxSettingsRefreshTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled, let self else { return } + guard !self.irxSettingsContinuations.isEmpty else { + self.irxSettingsRefreshTask = nil + return + } + self.publishIrxSettingsUpdate() + } + } + } +} + +// MARK: - Pure projection (unit-tested in MobileHostIrxSettingsMappingTests) + +extension MobileHostIrxRuntime { + nonisolated static func settingsSnapshot( + phase: SettingsPhase, + forceRelayOnly: Bool, + endpointOnline: Bool, + homeRelayURL: String?, + relayFleet: [String], + hasTrustSnapshot: Bool, + hadLiveDiscovery: Bool, + credentialExpiry: Date? + ) -> CmxIrohSettingsSnapshot { + let selectedPath = settingsSelectedPath( + phase: phase, + endpointOnline: endpointOnline, + homeRelayURL: homeRelayURL + ) + #if DEBUG + let debugMode: CmxIrohTransportVerificationMode? = + forceRelayOnly ? .relayOnly : .automatic + #else + let debugMode: CmxIrohTransportVerificationMode? = nil + #endif + return CmxIrohSettingsSnapshot( + runtimeStatus: settingsRuntimeStatus( + phase: phase, + endpointOnline: endpointOnline, + selectedPath: selectedPath + ), + selectedTransportPath: selectedPath, + preference: .automatic, + pathPreference: forceRelayOnly ? .relayOnly : .automatic, + managedRelays: settingsManagedRelays( + relayFleet: relayFleet, + homeRelayURL: homeRelayURL + ), + customRelays: [], + policySource: hasTrustSnapshot + ? (hadLiveDiscovery ? .server : .cached) + : .unavailable, + policySequence: nil, + // The relay credentials' signed expiry is the truthful "policy" + // lifetime in irx: past it the endpoint loses relay authority + // until the autopilot mints again. + policyExpiresAt: credentialExpiry, + staleRelayIDs: [], + failureDescription: phase == .failed ? "irx-activation-failed" : nil, + debugTransportVerificationMode: debugMode + ) + } + + nonisolated static func settingsRuntimeStatus( + phase: SettingsPhase, + endpointOnline: Bool, + selectedPath: CmxIrohSelectedTransportPath + ) -> CmxIrohSettingsSnapshot.RuntimeStatus { + switch phase { + case .idle: + return .inactive + case .activating: + return .starting + case .failed: + return .degraded + case .active: + // An active runtime whose endpoint dropped is rebinding (the + // accept loop re-establishes it), not persistently failed. + guard endpointOnline else { return .starting } + return CmxIrohSettingsSnapshot.RuntimeStatus(activePath: selectedPath) + } + } + + /// Relay for now: direct paths are unwired in irx v1, so the only + /// attributable live path is the relay the endpoint homes on. + nonisolated static func settingsSelectedPath( + phase: SettingsPhase, + endpointOnline: Bool, + homeRelayURL: String? + ) -> CmxIrohSelectedTransportPath { + guard phase == .active, endpointOnline, let homeRelayURL, + let labels = relayLabels(for: homeRelayURL) + else { return .unavailable } + return .managedRelay(provider: labels.provider, region: labels.region) + } + + nonisolated static func settingsManagedRelays( + relayFleet: [String], + homeRelayURL: String? + ) -> [CmxIrohSettingsSnapshot.ManagedRelay] { + let homeHost = homeRelayURL.flatMap(relayHost) + var seenHosts = Set() + return relayFleet.compactMap { url in + guard let host = relayHost(url), seenHosts.insert(host).inserted, + let labels = relayLabels(for: url) + else { return nil } + return CmxIrohSettingsSnapshot.ManagedRelay( + id: host, + provider: labels.provider, + region: labels.region, + url: url, + isSelected: host == homeHost + ) + } + } + + /// Data-derived display labels for a relay URL: region is the host's + /// first DNS label (the fleet's region prefix, e.g. `use4`), provider is + /// the remaining host. No signed catalog exists in irx to source labels. + nonisolated static func relayLabels( + for url: String + ) -> (provider: String, region: String)? { + guard let host = relayHost(url) else { return nil } + let labels = host.split(separator: ".") + guard let first = labels.first else { return nil } + guard labels.count > 1 else { return (provider: host, region: host) } + return ( + provider: labels.dropFirst().joined(separator: "."), + region: String(first).uppercased() + ) + } + + /// Canonical relay host: lowercased, FQDN trailing dot stripped (the + /// iroh driver reports its home relay with one), used for identity and + /// selected-relay matching. + nonisolated static func relayHost(_ url: String) -> String? { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard let host = URLComponents(string: trimmed)?.host, !host.isEmpty + else { return nil } + let canonical = host.hasSuffix(".") ? String(host.dropLast()) : host + guard !canonical.isEmpty else { return nil } + return canonical.lowercased() + } +} diff --git a/Sources/Mobile/MobileHostIrxRuntime.swift b/Sources/Mobile/MobileHostIrxRuntime.swift index 5c1fb0fb980f..789c44c44b51 100644 --- a/Sources/Mobile/MobileHostIrxRuntime.swift +++ b/Sources/Mobile/MobileHostIrxRuntime.swift @@ -58,13 +58,45 @@ final class MobileHostIrxRuntime { /// Changes on every (de)activation; per-connection supervisors compare it. private var generationToken = UUID() + /// Coarse lifecycle mirror for the Settings Networking section (see + /// `MobileHostIrxRuntime+SettingsControl`). `failed` means the last + /// activation attempt errored and the retry ladder owns recovery; it is + /// only cleared by a successful activation or an account change. + enum SettingsPhase: Equatable { + case idle + case activating + case active + case failed + } + + private(set) var settingsPhase: SettingsPhase = .idle + /// True once an authenticated broker discovery succeeded during the + /// current activation, so Settings can report the relay fleet as + /// server-verified rather than served from the disk cache. + private(set) var hadLiveDiscoveryThisRun = false + /// Live settings-snapshot subscribers (`irohSettingsUpdates()`). + var irxSettingsContinuations: [UUID: AsyncStream.Continuation] = [:] + /// Periodic re-yield loop; runs only while subscribers exist. + var irxSettingsRefreshTask: Task? + private var stateDirectory: URL? - private var brokerService: IrxBrokerService? - private var endpointSupervisor: IrxEndpointSupervisor? + private(set) var brokerService: IrxBrokerService? + private(set) var endpointSupervisor: IrxEndpointSupervisor? private var autopilot: IrxRelayCredentialAutopilot? private var registry: IrxServerSessionRegistry? private var acceptLoop: Task? private var localBinding: IrxBindingSnapshot? + /// The always-on fact channel to the per-account control-plane DO: the + /// host publishes hint announcements on it (instant propagation to + /// phones) and ingests pushed relay passes. Never on any serving path. + private var controlPlane: IrxControlPlaneClient? + /// The CURRENT device-list lease the accept loop judges against: + /// synchronous O(1) reads, atomically swapped on every directory apply, + /// cleared (fail closed) on deactivation. + private var deviceListBox: IrxDeviceListCurrent? + /// Durable home of the lease (Keychain in Release, dev file store in + /// DEBUG), loaded at activation so admission works offline. + private var deviceListStore: IrxDeviceListStore? func configure(auth: AuthCoordinator) { self.auth = auth @@ -86,12 +118,36 @@ final class MobileHostIrxRuntime { } } + /// Sets the settings-facing phase and pushes a fresh snapshot to any + /// Settings subscribers. Safe to call redundantly; only changes publish. + func setSettingsPhase(_ phase: SettingsPhase) { + guard phase != settingsPhase else { return } + settingsPhase = phase + publishIrxSettingsUpdate() + } + + /// Marks the current run as having completed a live (network) broker + /// discovery, so the Settings policy source reads "server". Called from + /// activation and from the settings refresh action. + func noteLiveDiscoverySucceeded() { + hadLiveDiscoveryThisRun = true + } + private func transition(to accountID: String?) async { guard accountID != activeAccountID else { return } + // Explicit sign-out (account -> nil): erase the persisted device-list + // lease alongside the in-memory clear deactivate() performs, in the + // same breath the account's other cached authorization material + // stops being usable. An account SWITCH keeps the old account's + // lease (it is account-scoped and TTL-bounded). + if accountID == nil, let deviceListStore { + await deviceListStore.clear() + } await deactivate() activeAccountID = accountID guard let accountID else { return } Self.journal.record("host-runtime", "activating", ["account": accountID]) + setSettingsPhase(.activating) activationTask = Task { @MainActor [weak self] in await self?.activate(accountID: accountID) } @@ -101,12 +157,21 @@ final class MobileHostIrxRuntime { guard let auth else { return } generationToken = UUID() let token = generationToken + // The control-plane client now starts EARLY in activation (before the + // broker calls that can throw), so a retry after a mid-activation + // failure must stop the previous attempt's client instead of leaking + // its reconnect loop beside a fresh one. + if let controlPlane { + await controlPlane.stop() + self.controlPlane = nil + } let tag = MobileHostIrohRuntime.currentTag() guard let brokerBaseURL = AuthEnvironment.irohBrokerBaseURL, let namespace = CmxIrohMacBundleNamespace( bundleIdentifier: Bundle.main.bundleIdentifier) else { Self.journal.record("host-runtime", "activation-failed", ["reason": "environment"]) + setSettingsPhase(.failed) return } let appSupport = FileManager.default.urls( @@ -146,7 +211,10 @@ final class MobileHostIrxRuntime { platform: .mac, displayName: Host.current().localizedName, cacheDirectory: stateDir, - identityGeneration: material.generation + identityGeneration: material.generation, + // Release: broker caches live in the Keychain, scoped + // per account + backend; DEBUG stays on the JSON files. + accountID: accountID ), identity: identity, accessTokenPair: { [weak auth] in @@ -182,9 +250,96 @@ final class MobileHostIrxRuntime { let pilot = IrxRelayCredentialAutopilot( broker: broker, endpoint: supervisor, journal: Self.journal) autopilot = pilot - // Registration FIRST: non-legacy namespaces need the binding - // authorization it establishes before any other broker call - // (relay minting, discovery) is accepted. + registry = IrxServerSessionRegistry(journal: Self.journal) + + // DEVICE LIST: the admission authority. Load the persisted lease + // BEFORE anything network-bound so admission works offline, and + // start the control-plane client FIRST so the fresh directory + // (and any revocation) lands as early as possible. Neither step + // blocks the endpoint bind. + let listStore = IrxDeviceListStore( + secureStore: Self.deviceListSecureStore(stateDirectory: stateDir), + accountID: accountID, + backendHost: brokerBaseURL.host ?? "unknown-broker", + journal: Self.journal + ) + deviceListStore = listStore + let listBox = IrxDeviceListCurrent() + deviceListBox = listBox + if let persisted = await listStore.loadPersisted() { + listBox.replace(persisted) + } + guard generationToken == token else { return } + + // Control-plane socket: hint announcements out (instant phone + // propagation, the signed HTTPS registration stays authoritative), + // pushed relay passes in (same mint rules as HTTPS), and the + // device-list directory in (admission authority). + let control: IrxControlPlaneClient? + if let controlURL = PresenceHeartbeatClient.resolvedServiceURL() { + let client = IrxControlPlaneClient( + configuration: .init( + socketURL: controlURL + .appendingPathComponent("v1/control/socket"), + endpointIDHex: identity.endpointIDHex, + // Phase A: passes stay on the HTTPS autopilot (with + // the stale-connection retry). The broker mint + // requires an endpoint-signed proof for non-legacy + // namespaces, which a bearer-only proxy cannot + // satisfy; flip when proof pass-through ships. + wantPasses: false, + cacheDirectory: stateDir, + clientInfo: IrxCtlClientInfo( + deviceID: deviceID, + platform: "mac", + appVersion: IrxCtlClientInfo.appVersionString( + infoDictionary: Bundle.main.infoDictionary), + releaseTrack: Self.hostReleaseTrack(), + capabilities: ["cmux.irx.v2", "list-auth"] + ), + clientNamespace: namespace.rawValue + ), + tokenPair: { [weak auth] in + guard let auth else { return nil } + let session = try await auth.authenticatedSessionSnapshot() + return (session.accessToken, session.refreshToken) + }, + handlers: .init( + onRelayPasses: { [weak self, weak broker, weak supervisor, weak pilot] pushed in + guard let broker, let supervisor, let pilot, + let accepted = await broker + .acceptPushedRelayCredentials(pushed) + else { return false } + await supervisor.rotateCredentials(accepted) + await pilot.kick() + await self?.publishIrxSettingsUpdate() + return true + }, + // The host dials no peers; hint facts are for clients. + onHintUpdate: { _, _ in true }, + onDirectory: { _ in true }, + onSnapshotComplete: { _ in }, + onDirectoryFact: { [weak self] fact in + await self?.applyDeviceListFact(fact) ?? false + }, + onFreshness: { [weak self] rev, issuedAt in + await self?.applyDeviceListFreshness( + rev: rev, issuedAt: issuedAt) + } + ), + journal: Self.journal + ) + controlPlane = client + control = client + await client.start() + } else { + control = nil + } + + // Registration FIRST among the broker calls: non-legacy + // namespaces need the binding authorization it establishes + // before any other broker call (relay minting, discovery) is + // accepted. let binding = try await broker.register( pairingEnabled: true, relayURLHint: nil @@ -192,6 +347,7 @@ final class MobileHostIrxRuntime { localBinding = binding let credentials = try await pilot.usableCredentials() _ = try await broker.discover() + noteLiveDiscoverySucceeded() guard generationToken == token else { return } _ = try await supervisor.readyEndpoint(credentials: credentials) @@ -199,16 +355,27 @@ final class MobileHostIrxRuntime { // refresh the binding so registry consumers see it too. let homeRelay = await supervisor.homeRelayURL() ?? credentials.first?.relayURL _ = try? await broker.register(pairingEnabled: true, relayURLHint: homeRelay) + if let control, let homeRelay { + await control.publishHint(homeRelayURL: homeRelay) + } // Relay hints are server-capped at 1h; refresh the registration on - // every credential rotation so the advertised hint never expires. - await pilot.setOnRotation { [weak broker, weak supervisor] in + // every credential rotation so the advertised hint never expires, + // and announce it over the socket so phones hear about relay + // moves in milliseconds instead of at the next registry read. + await pilot.setOnRotation { [weak self, weak broker, weak supervisor] in guard let broker, let supervisor else { return } let relay = await supervisor.homeRelayURL() try? await broker.registerHintIfNeeded( pairingEnabled: true, relayURLHint: relay) + if let relay, let control { + await control.publishHint(homeRelayURL: relay) + } + // Credential rotation (and any home-relay move it reveals) + // changes the Settings snapshot's policy expiry and relay + // selection; push it to live subscribers. + await self?.publishIrxSettingsUpdate() } await pilot.start() - registry = IrxServerSessionRegistry(journal: Self.journal) publishRoute(identity: identity, relayURL: homeRelay) startAcceptLoop(token: token) @@ -221,11 +388,17 @@ final class MobileHostIrxRuntime { "path_mode": Self.forceRelayOnly ? "relay-only" : "automatic", ] ) + setSettingsPhase(.active) } catch { Self.journal.record( "host-runtime", "activation-failed", ["reason": String(describing: error)] ) + if generationToken == token { + // Stays failed across the retry ladder (no activating/failed + // flicker in Settings); success or an account change clears it. + setSettingsPhase(.failed) + } // One bounded retry ladder, reset by the auth observation loop on // account change: retry activation after 5s while still desired. try? await Task.sleep(for: .seconds(5)) @@ -249,18 +422,115 @@ final class MobileHostIrxRuntime { await registry.closeAll(code: .hostShutdown) } registry = nil + if let controlPlane { + await controlPlane.stop() + } + controlPlane = nil + // Fail closed immediately: with the box cleared, the accept loop's + // judge denies every hello. Persisted clearing happens only on + // explicit sign-out (see `transition(to:)`), so a relaunch on the + // same account keeps working offline. + deviceListBox?.clear() + deviceListBox = nil + deviceListStore = nil if let endpointSupervisor { await endpointSupervisor.close() } endpointSupervisor = nil brokerService = nil localBinding = nil + hadLiveDiscoveryThisRun = false + setSettingsPhase(.idle) if Self.isEnabled { MobileHostPublicStatusCache.update(irohIdentity: nil) } Self.journal.record("host-runtime", "deactivated") } + // MARK: - Device list (admission authority) + + /// Applies a pushed directory fact: build the lease snapshot, persist it, + /// swap it into the accept path atomically, acknowledge the revision, + /// then enforce it on LIVE sessions (a revoked or delisted device is cut + /// now with `.revoked`, not at its next admission). + private func applyDeviceListFact(_ fact: IrxCtlDirectoryFact) async -> Bool { + guard let deviceListBox, let deviceListStore else { return false } + if let current = deviceListBox.current, fact.rev <= current.rev { + Self.journal.record( + "host-runtime", "device-list-stale-rev", + ["rev": String(fact.rev), "have": String(current.rev)] + ) + return true + } + let snapshot = IrxDeviceListSnapshot( + fact: fact, + receivedAtWall: Date(), + receivedAtMonotonic: .now + ) + guard await deviceListStore.persist(snapshot) else { return false } + deviceListBox.replace(snapshot) + Self.journal.record( + "host-runtime", "device-list-applied", + ["rev": String(fact.rev), "entries": String(snapshot.entries.count)] + ) + if let registry { + await registry.closeAll(code: .revoked) { endpointIDHex in + guard let entry = snapshot.entries[endpointIDHex] else { return true } + return entry.revoked + } + } + return true + } + + /// An explicit freshness re-stamp (`current`, or a `snapshot_complete` + /// carrying `issuedAt`) extends the CURRENT lease without changing its + /// membership. + private func applyDeviceListFreshness(rev: Int, issuedAt: Date) async { + guard let deviceListBox, let deviceListStore else { return } + guard + let updated = deviceListBox.restamp( + rev: rev, + issuedAt: issuedAt, + receivedAtWall: Date(), + receivedAtMonotonic: .now + ) + else { return } + await deviceListStore.persist(updated) + Self.journal.record( + "host-runtime", "device-list-restamped", ["rev": String(rev)] + ) + } + + /// The lease's durable backend: Keychain in Release, the development + /// file store inside the irx state directory in DEBUG (the exact split + /// every other secure store uses; ad-hoc DEBUG builds lack the + /// data-protection Keychain entitlement). + private nonisolated static func deviceListSecureStore( + stateDirectory: URL + ) -> any CmxIrohSecureCredentialStoring { + #if DEBUG + CmxIrohDevelopmentFileCredentialStore( + directory: stateDirectory.appendingPathComponent( + "device-list", isDirectory: true) + ) + #else + CmxIrohKeychainCredentialStore( + service: "com.cmuxterm.irx.device-list.v1" + ) + #endif + } + + /// The Mac build's control-plane release track: DEBUG builds are "dev", + /// nightly-flavored bundle ids are "nightly", everything else "stable". + private nonisolated static func hostReleaseTrack() -> String { + #if DEBUG + return "dev" + #else + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "" + return bundleIdentifier.contains("nightly") ? "nightly" : "stable" + #endif + } + /// Publishes the irx endpoint as THE iroh route: attach tickets, host /// status, and presence all advertise it, so phones dial irx. v1 hints /// carry the relay URL only (relay-first; private hints require network @@ -290,21 +560,22 @@ final class MobileHostIrxRuntime { } private func startAcceptLoop(token: UUID) { - guard let endpointSupervisor, let brokerService, let registry, let localBinding + guard let endpointSupervisor, let brokerService, let registry, let localBinding, + let deviceListBox else { return } let journal = Self.journal guard let acceptor = try? acceptorPeer(binding: localBinding) else { journal.record("host-runtime", "activation-failed", ["reason": "acceptor-tuple"]) return } - // Admission reads the persisted trust snapshot synchronously; it - // never awaits the broker (steady-state independence). - guard let stateDirectory else { return } - let judge = IrxGrantJudge( - acceptor: acceptor, - trustProvider: { IrxDiskCacheTrustReader.read(stateDirectory: stateDirectory) } - ) - let trustSnapshot = { IrxDiskCacheTrustReader.read(stateDirectory: stateDirectory) } + // LIST AUTH: irx admission judges the TLS key against the current + // device-list lease, synchronously and O(1) (an atomic box read; no + // actor, no disk, no network). The hello's grant is ignored. + let judge = IrxListJudge(current: deviceListBox, journal: journal) + // The legacy dialect (old phones) still verifies pair grants against + // the persisted trust snapshot, read through the broker's cache so + // the Release keychain migration cannot strand it. + let trustSnapshot = { brokerService.cachedTrustForAdmission() } let brokerClient = brokerService.hostBrokerClient acceptLoop = Task { [weak self] in journal.record("host-runtime", "accept-loop-started") @@ -369,7 +640,7 @@ final class MobileHostIrxRuntime { private func superviseConnection( _ irx: IrxConnection, - judge: IrxGrantJudge, + judge: IrxListJudge, registry: IrxServerSessionRegistry, token: UUID ) async { @@ -381,7 +652,25 @@ final class MobileHostIrxRuntime { journal: journal ) else { return } - await registry.admit(deviceID: peer.deviceID, sessionID: sessionID, connection: irx) + let registered = await registry.admit( + deviceID: peer.deviceID, + sessionID: sessionID, + connection: irx, + stillAuthorized: { endpointIDHex in + do { + _ = try judge.judgment()(nil, endpointIDHex) + return true + } catch { + return false + } + } + ) + guard registered else { return } + // Automatic path mode: authorize NAT traversal so the admitted session + // can upgrade to a direct/LAN path make-before-break. + if !Self.forceRelayOnly { + await irx.authorizeDirectPaths() + } let admittedPeer: CmxIrohAdmittedPeer do { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cb1ba751d46c..ea66b6ad0669 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1557,7 +1557,9 @@ 1B0B09010000000000000002 /* MobileHostIrohRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */; }; C1A070000000000000000004 /* MobileHostIrohServerEventWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000014 /* MobileHostIrohServerEventWriter.swift */; }; C1B1810000000000000003 /* MobileHostIrxLegacyDialectServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000013 /* MobileHostIrxLegacyDialectServer.swift */; }; + C1B1810000000000000004 /* MobileHostIrxRuntime+SettingsControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000014 /* MobileHostIrxRuntime+SettingsControl.swift */; }; C1B1810000000000000001 /* MobileHostIrxRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000011 /* MobileHostIrxRuntime.swift */; }; + C1B1810000000000000005 /* MobileHostIrxSettingsMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000015 /* MobileHostIrxSettingsMappingTests.swift */; }; C1B1810000000000000002 /* MobileHostIrxTerminalLaneServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000012 /* MobileHostIrxTerminalLaneServer.swift */; }; DE71CE000000000000000008 /* MobileHostNetworkPathMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000007 /* MobileHostNetworkPathMonitor.swift */; }; DE71CE000000000000000006 /* MobileHostNetworkPathRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE71CE000000000000000005 /* MobileHostNetworkPathRefreshTests.swift */; }; @@ -4530,7 +4532,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohRuntime.swift; sourceTree = ""; }; C1A070000000000000000014 /* MobileHostIrohServerEventWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohServerEventWriter.swift; sourceTree = ""; }; C1B1810000000000000013 /* MobileHostIrxLegacyDialectServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrxLegacyDialectServer.swift; sourceTree = ""; }; + C1B1810000000000000014 /* MobileHostIrxRuntime+SettingsControl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrxRuntime+SettingsControl.swift"; sourceTree = ""; }; C1B1810000000000000011 /* MobileHostIrxRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrxRuntime.swift; sourceTree = ""; }; + C1B1810000000000000015 /* MobileHostIrxSettingsMappingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrxSettingsMappingTests.swift; sourceTree = ""; }; C1B1810000000000000012 /* MobileHostIrxTerminalLaneServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrxTerminalLaneServer.swift; sourceTree = ""; }; DE71CE000000000000000007 /* MobileHostNetworkPathMonitor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostNetworkPathMonitor.swift; sourceTree = ""; }; DE71CE000000000000000005 /* MobileHostNetworkPathRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileHostNetworkPathRefreshTests.swift; sourceTree = ""; }; @@ -6277,6 +6281,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A17070900000000000000001 /* MobileHostIrohApplicationLaneRouter.swift */, C1A070000000000000000014 /* MobileHostIrohServerEventWriter.swift */, C1B1810000000000000011 /* MobileHostIrxRuntime.swift */, + C1B1810000000000000014 /* MobileHostIrxRuntime+SettingsControl.swift */, C1B1810000000000000013 /* MobileHostIrxLegacyDialectServer.swift */, C1B1810000000000000012 /* MobileHostIrxTerminalLaneServer.swift */, C1A070000000000000000015 /* MobileHostTransportAuthorization.swift */, @@ -9056,6 +9061,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE73840000000000000002 /* MobileHostWorkspaceTicketAuthorizationTests.swift */, ABMDPARTRES0000000000002 /* MobilePanelArtifactResolutionTests.swift */, B8B056D80000000000000002 /* MobileHostIdentityTests.swift */, + C1B1810000000000000015 /* MobileHostIrxSettingsMappingTests.swift */, A7C0F0010000000000000001 /* MacPairedMacBackupPublisherScopeTests.swift */, 5E2701030000000000000002 /* MacSentryStartupPolicyTests.swift */, ); @@ -10482,6 +10488,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 1B0B09010000000000000002 /* MobileHostIrohRuntime.swift in Sources */, C1A070000000000000000004 /* MobileHostIrohServerEventWriter.swift in Sources */, C1B1810000000000000003 /* MobileHostIrxLegacyDialectServer.swift in Sources */, + C1B1810000000000000004 /* MobileHostIrxRuntime+SettingsControl.swift in Sources */, C1B1810000000000000001 /* MobileHostIrxRuntime.swift in Sources */, C1B1810000000000000002 /* MobileHostIrxTerminalLaneServer.swift in Sources */, DE71CE000000000000000008 /* MobileHostNetworkPathMonitor.swift in Sources */, @@ -12118,6 +12125,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C1A071000000000000000003 /* MobileHostConnectionLifecycleTests.swift in Sources */, B8B056D80000000000000001 /* MobileHostIdentityTests.swift in Sources */, C1A071000000000000000001 /* MobileHostIrohAdmissionTests.swift in Sources */, + C1B1810000000000000005 /* MobileHostIrxSettingsMappingTests.swift in Sources */, DE71CE000000000000000006 /* MobileHostNetworkPathRefreshTests.swift in Sources */, A1B2C3D4E5F60718293A4C03 /* MobileHostOrderedInputTests.swift in Sources */, C0DE10510000000000000001 /* MobileHostServiceSettingsTests.swift in Sources */, diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift index a2de3a23f4ca..e0aa1f4aaf95 100644 --- a/cmuxTests/AuthEnvironmentTests.swift +++ b/cmuxTests/AuthEnvironmentTests.swift @@ -84,6 +84,17 @@ struct AuthEnvironmentTests { ) == nil) } + @Test("device registry publishes to shared staging in debug so dev phones read fresh routes") + func deviceRegistryPublishesToSharedStagingInDebug() { + let localVMAPI = URL(string: "http://localhost:9450")! + #expect(AuthEnvironment.resolvedDeviceRegistryAPIBaseURL( + isDebugBuild: true, vmAPIBaseURL: localVMAPI + ).absoluteString == "https://cmux-staging.vercel.app") + #expect(AuthEnvironment.resolvedDeviceRegistryAPIBaseURL( + isDebugBuild: false, vmAPIBaseURL: localVMAPI + ) == localVMAPI) + } + @Test("debug callback scheme uses sanitized tag") func debugCallbackSchemeUsesSanitizedTag() { #expect( diff --git a/cmuxTests/MobileHostIrxSettingsMappingTests.swift b/cmuxTests/MobileHostIrxSettingsMappingTests.swift new file mode 100644 index 000000000000..2e30a034674a --- /dev/null +++ b/cmuxTests/MobileHostIrxSettingsMappingTests.swift @@ -0,0 +1,166 @@ +import CMUXMobileCore +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Pure-projection coverage for the irx-backed Settings Networking snapshot +/// (`MobileHostIrxRuntime+SettingsControl`). +struct MobileHostIrxSettingsMappingTests { + private let homeRelay = "https://use4.relay.cmux.dev./" + private let fleet = [ + "https://use4.relay.cmux.dev/", + "https://usw1.relay.cmux.dev/", + ] + + @Test func runtimeStatusFollowsLifecyclePhase() { + #expect( + MobileHostIrxRuntime.settingsRuntimeStatus( + phase: .idle, endpointOnline: false, selectedPath: .unavailable + ) == .inactive) + #expect( + MobileHostIrxRuntime.settingsRuntimeStatus( + phase: .activating, endpointOnline: false, selectedPath: .unavailable + ) == .starting) + #expect( + MobileHostIrxRuntime.settingsRuntimeStatus( + phase: .failed, endpointOnline: false, selectedPath: .unavailable + ) == .degraded) + } + + @Test func activeOnlineRuntimeReportsTheRelayPath() { + let path = MobileHostIrxRuntime.settingsSelectedPath( + phase: .active, endpointOnline: true, homeRelayURL: homeRelay) + #expect(path == .managedRelay(provider: "relay.cmux.dev", region: "USE4")) + #expect( + MobileHostIrxRuntime.settingsRuntimeStatus( + phase: .active, endpointOnline: true, selectedPath: path + ) == .relayed(provider: "relay.cmux.dev", region: "USE4")) + } + + @Test func activeRuntimeWithDroppedEndpointReportsStartingNotDegraded() { + // The accept loop rebinds a dropped endpoint; that transient must not + // read as a persistent failure. + #expect( + MobileHostIrxRuntime.settingsRuntimeStatus( + phase: .active, endpointOnline: false, selectedPath: .unavailable + ) == .starting) + #expect( + MobileHostIrxRuntime.settingsSelectedPath( + phase: .active, endpointOnline: false, homeRelayURL: homeRelay + ) == .unavailable) + } + + @Test func activeOnlineWithoutHomeRelayReportsEndpointActive() { + let path = MobileHostIrxRuntime.settingsSelectedPath( + phase: .active, endpointOnline: true, homeRelayURL: nil) + #expect(path == .unavailable) + #expect( + MobileHostIrxRuntime.settingsRuntimeStatus( + phase: .active, endpointOnline: true, selectedPath: path + ) == .active) + } + + @Test func managedRelaysMarkTheActualHomeRelaySelected() { + let relays = MobileHostIrxRuntime.settingsManagedRelays( + relayFleet: fleet, homeRelayURL: homeRelay) + #expect(relays.map(\.id) == ["use4.relay.cmux.dev", "usw1.relay.cmux.dev"]) + #expect(relays.map(\.isSelected) == [true, false]) + #expect(relays[0].region == "USE4") + #expect(relays[0].provider == "relay.cmux.dev") + #expect(relays[0].url == fleet[0]) + } + + @Test func managedRelaysDeduplicateByCanonicalHost() { + let relays = MobileHostIrxRuntime.settingsManagedRelays( + relayFleet: [ + "https://use4.relay.cmux.dev/", + "https://USE4.relay.cmux.dev./", + ], + homeRelayURL: nil + ) + #expect(relays.count == 1) + #expect(relays.allSatisfy { !$0.isSelected }) + } + + @Test func relayHostCanonicalizesCaseAndFQDNTrailingDot() { + #expect( + MobileHostIrxRuntime.relayHost("https://USE4.Relay.cmux.dev./") + == "use4.relay.cmux.dev") + #expect(MobileHostIrxRuntime.relayHost("not a url") == nil) + #expect(MobileHostIrxRuntime.relayHost("https:///nohost") == nil) + } + + @Test func policySourceIsServerOnlyAfterALiveDiscovery() { + func snapshot( + hasTrust: Bool, live: Bool + ) -> CmxIrohSettingsSnapshot { + MobileHostIrxRuntime.settingsSnapshot( + phase: .active, + forceRelayOnly: false, + endpointOnline: true, + homeRelayURL: homeRelay, + relayFleet: fleet, + hasTrustSnapshot: hasTrust, + hadLiveDiscovery: live, + credentialExpiry: nil + ) + } + #expect(snapshot(hasTrust: true, live: true).policySource == .server) + #expect(snapshot(hasTrust: true, live: false).policySource == .cached) + #expect(snapshot(hasTrust: false, live: false).policySource == .unavailable) + } + + @Test func snapshotCarriesCredentialExpiryAsPolicyLifetime() { + let expiry = Date(timeIntervalSinceReferenceDate: 1_000) + let snapshot = MobileHostIrxRuntime.settingsSnapshot( + phase: .active, + forceRelayOnly: false, + endpointOnline: true, + homeRelayURL: homeRelay, + relayFleet: fleet, + hasTrustSnapshot: true, + hadLiveDiscovery: true, + credentialExpiry: expiry + ) + #expect(snapshot.policyExpiresAt == expiry) + #expect(snapshot.preference == .automatic) + #expect(snapshot.customRelays.isEmpty) + #expect(snapshot.staleRelayIDs.isEmpty) + #expect(snapshot.failureDescription == nil) + } + + @Test func failedPhaseSurfacesAFailureDescriptionForTheAttentionNote() { + let snapshot = MobileHostIrxRuntime.settingsSnapshot( + phase: .failed, + forceRelayOnly: false, + endpointOnline: false, + homeRelayURL: nil, + relayFleet: [], + hasTrustSnapshot: false, + hadLiveDiscovery: false, + credentialExpiry: nil + ) + #expect(snapshot.runtimeStatus == .degraded) + #expect(snapshot.failureDescription != nil) + } + + @Test func unsupportedMutationsThrowExplicitly() async { + let runtime = await MainActor.run { MobileHostIrxRuntime.shared } + await #expect(throws: MobileHostIrxSettingsUnsupportedError.self) { + try await runtime.setIrohRelayPreference(.custom) + } + await #expect(throws: MobileHostIrxSettingsUnsupportedError.self) { + try await runtime.removeIrohCustomRelay(id: "any") + } + // Automatic already holds under irx, so re-selecting it is an + // idempotent success rather than a failure. + await #expect(throws: Never.self) { + try await runtime.setIrohRelayPreference(.automatic) + } + } +} diff --git a/ios/cmux/AppCompositionRoot.swift b/ios/cmux/AppCompositionRoot.swift index af6925a75e1b..36e901dfa94b 100644 --- a/ios/cmux/AppCompositionRoot.swift +++ b/ios/cmux/AppCompositionRoot.swift @@ -226,6 +226,13 @@ final class AppCompositionRoot { refreshToken: refreshToken ) } + if let irx { + // Drop the device-list lease (memory, Keychain/file, + // UI projection) with the account's other state. + group.addTask { + await irx.handleSignOut() + } + } } await diagnosticLog.clear() } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 01ff8122a540..e14dac598573 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -23988,6 +23988,74 @@ } } }, + "computers.listauth.unverified.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not verified on the new connection system yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい接続システムでまだ確認されていません" + } + } + } + }, + "computers.listauth.unverified.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "It may be running an older cmux version. Update the Mac, or if it's already updated, open cmux on it once to verify." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "古いバージョンの cmux が動作している可能性があります。Mac をアップデートするか、すでに最新の場合は、その Mac で cmux を一度開いて確認してください。" + } + } + } + }, + "computers.version.outdated.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac update required" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac のアップデートが必要です" + } + } + } + }, + "computers.version.outdated.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Mac is running %@. Update it to %@ or later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac は %@ を実行しています。%@ 以降にアップデートしてください。" + } + } + } + }, "mobile.connectionsUpdate.macUpdate.detail.official": { "extractionState": "manual", "localizations": { @@ -24068,7 +24136,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "「Macをスリープさせない」を操作するには、このMacに接続してください。" + "value": "「Macをスリープさせない」を操作するには、このMacに接続してください。" } } } diff --git a/ios/cmux/cmuxApp.swift b/ios/cmux/cmuxApp.swift index 65ee8cc28164..005491b32958 100644 --- a/ios/cmux/cmuxApp.swift +++ b/ios/cmux/cmuxApp.swift @@ -69,7 +69,13 @@ struct cmuxApp: App { ) if irxEnabled { let coordinator = auth.coordinator - Task { await irx.configure(auth: coordinator, legacy: iroh) } + Task { + await irx.configure( + auth: coordinator, + legacy: iroh, + controlPlaneBaseURL: connectivityInvalidationBaseURL + ) + } } else { iroh.configure( auth: auth.coordinator, diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 9f05ca0d511d..593e8ec70662 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -3,6 +3,7 @@ import CmuxAuthRuntime public import CmuxIrohTransport import CmuxIrxTransport public import CmuxMobileRPC +import CmuxMobileShellModel public import Foundation /// iOS composition root for the irx transport (the from-scratch iroh rebuild @@ -42,6 +43,17 @@ public actor MobileIrxRuntimeComposition { case peerNotDiscovered } + /// Dial-gate refusals from the device-list lease. Deliberately NOT + /// ``IrxAdmissionDenied``: the peer engine treats these as ordinary + /// transient failures (backoff + redial), because a stale lease or a + /// directory that has not yet caught up heals as soon as the control + /// plane re-stamps. A revoked entry, by contrast, throws + /// `IrxAdmissionDenied(.revoked)` and parks the engine. + public enum DeviceListDialRefusal: Error, Sendable { + case staleLease + case unknownEndpoint + } + /// One journal for every irx component on the phone; the JSONL file lives /// in the app container's Documents so the soak analyzer can pull it with /// `simctl get_app_container`. @@ -65,6 +77,9 @@ public actor MobileIrxRuntimeComposition { private let clientNamespace: String public nonisolated let tag: String private let stateDirectory: URL + /// The app's signed Keychain access group; scopes the Release device-list + /// and broker-cache Keychain items. + private let keychainAccessGroup: String? private weak var auth: AuthCoordinator? /// Identity donor (identity adoption): the legacy composition owns the @@ -74,6 +89,17 @@ public actor MobileIrxRuntimeComposition { private var endpointSupervisor: IrxEndpointSupervisor? private var autopilot: IrxRelayCredentialAutopilot? private var identity: IrxIdentity? + /// The always-on fact channel to the per-account control-plane DO. + /// Never on the dial path; delivers pushed passes, hint updates, and the + /// device-list directory (the dial-gate authority). + private var controlPlane: IrxControlPlaneClient? + private var controlPlaneBaseURL: URL? + /// The current device-list lease: consulted by the dial gate. nil until + /// a directory has EVER been received or restored (the bootstrap + /// exception: a fresh install may dial before its first directory). + private let deviceListBox = IrxDeviceListCurrent() + /// Durable lease storage (Keychain in Release, dev file store in DEBUG). + private var deviceListStore: IrxDeviceListStore? private var provisioningTask: Task? private var provisionInFlight: Task? /// One reconnect owner per Mac endpoint (contract: the single dialer). @@ -94,7 +120,7 @@ public actor MobileIrxRuntimeComposition { keychainAccessGroup: String? = nil, defaults: UserDefaults = .standard ) { - _ = keychainAccessGroup + self.keychainAccessGroup = keychainAccessGroup _ = defaults let appNamespace = injectedAppNamespace ?? MobileIOSAppNamespace(bundleIdentifier: bundleIdentifier) @@ -145,10 +171,12 @@ public actor MobileIrxRuntimeComposition { public func configure( auth: AuthCoordinator, - legacy: MobileIrohRuntimeComposition? = nil + legacy: MobileIrohRuntimeComposition? = nil, + controlPlaneBaseURL: URL? = nil ) { self.auth = auth legacyComposition = legacy + self.controlPlaneBaseURL = controlPlaneBaseURL Self.journal.record( "client-runtime", "configured", [ @@ -161,32 +189,293 @@ public actor MobileIrxRuntimeComposition { // Proactive provisioning so the user-visible connect is warm: // identity, binding, discovery, relay credentials all resolve in the // background at launch, never on the dial path. + // + // EVENT-DRIVEN on auth: setup never starts before sign-in is + // affirmatively complete. The identity stream's first element is the + // current state, so an already-signed-in launch provisions + // immediately, and a launch that races sign-in provisions the + // instant the session publishes instead of discovering it on a + // timer. Pre-auth attempts are not just wasted: a failed provision + // can burn broker registrations, and every registration write bumps + // the account route revision fleet-wide. provisioningTask?.cancel() provisioningTask = Task { [weak self] in - // Capped exponential backoff: a persistent broker-side failure - // must degrade to a gentle poll, never a 2s hammer (each failed - // attempt can hit registration, and registration writes bump the - // account route revision fleet-wide). - var delay: Duration = .seconds(2) - while !Task.isCancelled { - if await self?.provisionIfPossible() == true { - return - } - try? await Task.sleep(for: delay) - delay = min(delay * 2, .seconds(30)) + // Restored sessions first: bootstrap completion is the point + // where signed-in state is definitively known, and a session + // restored from the keychain may have published before this + // subscription existed. Checking directly here means a + // signed-in launch provisions immediately without depending on + // catching that publish. + await auth.awaitBootstrapped() + guard !Task.isCancelled else { return } + Self.journal.record("client-runtime", "auth-gate-bootstrapped") + if await self?.provisionSignedInWithRetry() == true { return } + // Fresh sign-ins and account transitions: provision the instant + // the session publishes. Still zero pre-auth attempts. + for await identity in await auth.authenticatedSessionIdentities() { + guard !Task.isCancelled else { return } + Self.journal.record( + "client-runtime", "auth-gate-identity", + ["signed_in": String(identity != nil)] + ) + guard identity != nil else { continue } + if await self?.provisionSignedInWithRetry() == true { return } } } } + /// Provisions with capped backoff. Returns true on success; returns + /// false immediately when not signed in (the caller's auth signal owns + /// the next attempt, so no pre-auth retries ever run). + private func provisionSignedInWithRetry() async -> Bool { + guard let auth else { return false } + Self.journal.record("client-runtime", "auth-gate-snapshot-check") + guard (try? await auth.authenticatedSessionSnapshot()) != nil else { + Self.journal.record("client-runtime", "auth-gate-not-signed-in") + return false + } + Self.journal.record("client-runtime", "auth-gate-signed-in") + var delay: Duration = .seconds(1) + while !Task.isCancelled { + if await provisionIfPossible() { return true } + try? await Task.sleep(for: delay) + delay = min(delay * 2, .seconds(30)) + } + return false + } + /// Foreground kick: re-check credential freshness immediately (iOS - /// suspension pauses the autopilot's sleep). + /// suspension pauses the autopilot's sleep) and reconnect the control + /// socket, which resyncs facts from the persisted revision. public func didBecomeActive() async { await autopilot?.kick() + await controlPlane?.kick() for engine in enginesByPeer.values { - await engine.warmUp(trigger: "foreground") + await engine.foregroundKick() + } + } + + // MARK: - Control-plane fact ingestion + + private func startControlPlane(identity: IrxIdentity) { + guard controlPlane == nil, let controlPlaneBaseURL, let auth else { return } + let client = IrxControlPlaneClient( + configuration: .init( + socketURL: controlPlaneBaseURL + .appendingPathComponent("v1/control/socket"), + endpointIDHex: identity.endpointIDHex, + // Phase A: passes stay on the HTTPS autopilot (now hardened + // with stale-connection retry). The broker's mint endpoint + // requires an endpoint-signed proof for non-legacy + // namespaces, which the DO cannot mint bearer-only; flip + // this when proof pass-through ships. + wantPasses: false, + cacheDirectory: stateDirectory, + clientInfo: IrxCtlClientInfo( + deviceID: identity.deviceID, + platform: "ios", + appVersion: IrxCtlClientInfo.appVersionString( + infoDictionary: Bundle.main.infoDictionary), + releaseTrack: Self.clientReleaseTrack(), + capabilities: ["cmux.irx.v2", "list-auth"] + ), + clientNamespace: clientNamespace + ), + tokenPair: { [weak auth] in + guard let auth else { return nil } + let session = try await auth.authenticatedSessionSnapshot() + return (session.accessToken, session.refreshToken) + }, + handlers: .init( + onRelayPasses: { [weak self] credentials in + await self?.ingestPushedPasses(credentials) ?? false + }, + onHintUpdate: { [weak self] endpointIDHex, relayURL in + await self?.ingestHintUpdate( + endpointIDHex: endpointIDHex, relayURL: relayURL) ?? false + }, + onDirectory: { _ in true }, + onSnapshotComplete: { _ in }, + onDirectoryFact: { [weak self] fact in + await self?.applyDeviceListFact(fact) ?? false + }, + onFreshness: { [weak self] rev, issuedAt in + await self?.applyDeviceListFreshness(rev: rev, issuedAt: issuedAt) + } + ), + journal: Self.journal + ) + controlPlane = client + Task { await client.start() } + } + + // MARK: - Device list (dial-gate authority) + + /// The phone's iteration of the device-list apply: persist, swap the + /// dial-gate box, project into the UI state, acknowledge the revision. + /// (Enforcement on LIVE sessions is the Mac's job; the phone's gate + /// bites at the next dial.) + private func applyDeviceListFact(_ fact: IrxCtlDirectoryFact) async -> Bool { + if let current = deviceListBox.current, fact.rev <= current.rev { + Self.journal.record( + "client-runtime", "device-list-stale-rev", + ["rev": String(fact.rev), "have": String(current.rev)] + ) + return true + } + let snapshot = IrxDeviceListSnapshot( + fact: fact, + receivedAtWall: Date(), + receivedAtMonotonic: .now + ) + if let deviceListStore { + guard await deviceListStore.persist(snapshot) else { return false } + } + deviceListBox.replace(snapshot) + Self.journal.record( + "client-runtime", "device-list-applied", + ["rev": String(fact.rev), "entries": String(snapshot.entries.count)] + ) + await projectDeviceListForUI(snapshot) + return true + } + + private func applyDeviceListFreshness(rev: Int, issuedAt: Date) async { + guard + let updated = deviceListBox.restamp( + rev: rev, + issuedAt: issuedAt, + receivedAtWall: Date(), + receivedAtMonotonic: .now + ) + else { return } + if let deviceListStore { + await deviceListStore.persist(updated) + } + Self.journal.record( + "client-runtime", "device-list-restamped", ["rev": String(rev)] + ) + } + + /// Mirrors the lease into the @Observable UI state (Computers rows read + /// it to badge seeded Macs). + private func projectDeviceListForUI(_ snapshot: IrxDeviceListSnapshot) async { + let fresh = snapshot.isFresh(now: .now) + var byEndpoint: [String: MobileMacListAuthState.Entry] = [:] + var byDevice: [String: MobileMacListAuthState.Entry] = [:] + for (endpointIDHex, entry) in snapshot.entries { + let projected = MobileMacListAuthState.Entry( + status: entry.status, + revoked: entry.revoked, + isFresh: fresh, + appVersion: entry.appVersion, + minimumSupportedVersion: snapshot.minimumSupportedMacVersion + ) + byEndpoint[endpointIDHex] = projected + if let deviceID = entry.deviceID { + byDevice[deviceID] = projected + } + } + await MainActor.run { + MobileMacListAuthState.shared.replace( + entriesByEndpointID: byEndpoint, + entriesByDeviceID: byDevice, + minimumSupportedMacVersion: snapshot.minimumSupportedMacVersion + ) } } + /// UI/programmatic lookup: the peer's list-auth stance right now. + public func deviceListEntry( + endpointIDHex: String + ) -> (status: String, revoked: Bool, fresh: Bool)? { + guard let snapshot = deviceListBox.current, + let entry = snapshot.entries[endpointIDHex] + else { return nil } + return (entry.status, entry.revoked, snapshot.isFresh(now: .now)) + } + + /// Sign-out: drop the lease everywhere (memory, durable store, UI), so + /// the next account starts from its own directory. + public func handleSignOut() async { + deviceListBox.clear() + if let deviceListStore { + await deviceListStore.clear() + } + deviceListStore = nil + await MainActor.run { + MobileMacListAuthState.shared.clear() + } + Self.journal.record("client-runtime", "device-list-signed-out") + } + + /// The lease's durable backend, matching the identity/credential stores' + /// DEBUG/#else split exactly. + private nonisolated static func deviceListSecureStore( + stateDirectory: URL, + keychainAccessGroup: String? + ) -> any CmxIrohSecureCredentialStoring { + #if DEBUG + CmxIrohDevelopmentFileCredentialStore( + directory: stateDirectory.appendingPathComponent( + "device-list", isDirectory: true) + ) + #else + CmxIrohKeychainCredentialStore( + service: "com.cmuxterm.irx.device-list.v1", + accessGroup: keychainAccessGroup + ) + #endif + } + + /// The phone build's control-plane release track. Conservative: DEBUG is + /// "dev"; a bundle id carrying a ".beta" segment is "beta"; everything + /// else reports "appstore" (TestFlight and App Store share a bundle id, + /// so the wire cannot distinguish them here). + private nonisolated static func clientReleaseTrack( + bundleIdentifier: String? = Bundle.main.bundleIdentifier + ) -> String { + #if DEBUG + return "dev" + #else + return (bundleIdentifier ?? "").contains(".beta") ? "beta" : "appstore" + #endif + } + + /// Pushed passes flow through the broker's mint rules (fleet allowlist, + /// identity binding, monotonic freshness), then rotate make-before-break + /// and reset the autopilot timer so push and fallback never double-mint. + private func ingestPushedPasses(_ credentials: [IrxRelayCredential]) async -> Bool { + guard let broker, let endpointSupervisor, let autopilot else { return false } + guard let accepted = await broker.acceptPushedRelayCredentials(credentials) + else { return false } + await endpointSupervisor.rotateCredentials(accepted) + await autopilot.kick() + return true + } + + /// The event-driven relay race: a pushed hint that disagrees with the + /// route an in-flight dial used cancels that dial and redials at the + /// true relay. An admitted session is never touched, and an agreeing + /// hint (the overwhelmingly common case) is a no-op. + private func ingestHintUpdate(endpointIDHex: String, relayURL: String) async -> Bool { + let existing = routesByPeer[endpointIDHex] + guard existing?.relayURL != relayURL else { return true } + routesByPeer[endpointIDHex] = (relayURL, existing?.directAddresses ?? []) + Self.journal.record( + "client-runtime", "hint-adopted", + [ + "peer": String(endpointIDHex.prefix(12)), + "relay": relayURL, + "was": existing?.relayURL ?? "-", + ] + ) + if let engine = enginesByPeer[endpointIDHex] { + await engine.relayHintChanged(trigger: "ctl-hint-update") + } + return true + } + private func provisionIfPossible() async -> Bool { guard let auth else { return false } guard let session = try? await auth.authenticatedSessionSnapshot() else { @@ -252,7 +541,11 @@ public actor MobileIrxRuntimeComposition { platform: .ios, displayName: nil, cacheDirectory: stateDirectory, - identityGeneration: adopted.material.generation + identityGeneration: adopted.material.generation, + // Release: broker caches live in the Keychain, scoped per + // account + backend; DEBUG stays on the JSON files. + accountID: session.accountID, + keychainAccessGroup: keychainAccessGroup ), identity: identity, accessTokenPair: { [weak auth] in @@ -318,6 +611,24 @@ public actor MobileIrxRuntimeComposition { self.broker = broker endpointSupervisor = supervisor autopilot = pilot + // DEVICE LIST: restore the persisted lease before any dial so the + // gate (and the UI projection) work offline; the control-plane + // socket then refreshes it with live directory facts. + let listStore = IrxDeviceListStore( + secureStore: Self.deviceListSecureStore( + stateDirectory: stateDirectory, + keychainAccessGroup: keychainAccessGroup + ), + accountID: session.accountID, + backendHost: brokerBaseURL.host() ?? "unknown-broker", + journal: Self.journal + ) + deviceListStore = listStore + if let persisted = await listStore.loadPersisted() { + deviceListBox.replace(persisted) + await projectDeviceListForUI(persisted) + } + startControlPlane(identity: identity) return broker } @@ -416,15 +727,43 @@ public actor MobileIrxRuntimeComposition { return engine } - /// One dial: cached grant + cached credentials + ready endpoint, then - /// connect + one-round-trip admission. Broker calls happen only on cache - /// misses (first pairing with this Mac, or a stale grant). + /// Refuses a dial the device list forbids. FAIL CLOSED only when there + /// is something to judge: a snapshot exists (entry missing/revoked, or + /// the whole lease stale). BOOTSTRAP EXCEPTION: when NO directory has + /// ever been received or restored (fresh install, first ever dial racing + /// the first control-plane hello), the dial proceeds; the Mac's own list + /// judge remains the authority that actually admits. + private func enforceDialGate(peerHex: String) throws { + guard let snapshot = deviceListBox.current else { return } + let refuse: (String) -> Void = { reason in + Self.journal.record( + "client-dial", "dial-refused", + ["reason": reason, "peer": String(peerHex.prefix(12))] + ) + } + guard snapshot.isFresh(now: .now) else { + refuse("stale") + throw DeviceListDialRefusal.staleLease + } + guard let entry = snapshot.entries[peerHex] else { + refuse("absent") + throw DeviceListDialRefusal.unknownEndpoint + } + if entry.revoked { + refuse("revoked") + throw IrxAdmissionDenied(code: .revoked) + } + } + + /// One dial: cached credentials + ready endpoint, then connect + one + /// GRANTLESS round-trip admission (the Mac judges our TLS key against + /// its device list; no pair-grant fetch sits on this path anymore). private func dialOnce(peerHex: String) async throws -> IrxClientSession { let broker = try await provisionedBroker() guard let supervisor = endpointSupervisor, let autopilot else { throw CompositionError.notSignedIn } - let grant = try await resolvedGrant(peerHex: peerHex, broker: broker) + try enforceDialGate(peerHex: peerHex) let credentials = try await autopilot.usableCredentials() var relayURL = routesByPeer[peerHex]?.relayURL if relayURL == nil { @@ -456,51 +795,25 @@ public actor MobileIrxRuntimeComposition { ) let connection = try await supervisor.dial( address: address, credentials: credentials) - do { - let (admit, control) = try await IrxAdmission.performClient( - connection: connection, - grantJWS: grant.grantJWS, - journal: Self.journal - ) - // Credit the server-opened events lane now that admission holds. - await connection.raiseRemoteStreamCredit(bi: 0, uni: 4) - return IrxClientSession( - connection: connection, - admit: admit, - control: control, - establishedAt: Date() - ) - } catch let denial as IrxAdmissionDenied { - // A revoked/expired/mismatched grant can be stale cache: drop it - // so the NEXT dial re-mints instead of re-presenting the corpse. - if denial.code == .invalidGrant || denial.code == .grantExpired - || denial.code == .revoked - { - await broker.dropGrant(acceptorEndpointIDHex: peerHex) - } - throw denial - } - } - - private func resolvedGrant( - peerHex: String, - broker: IrxBrokerService - ) async throws -> IrxGrantSnapshot { - if let cached = await broker.cachedGrant(acceptorEndpointIDHex: peerHex) { - return cached - } - // First contact with this Mac: find its binding, mint a grant. - let discovery = try await broker.discover() - guard - let acceptorBinding = discovery.bindings.first(where: { - $0.endpointID.endpointID == peerHex && $0.platform == .mac - }) - else { - throw CompositionError.peerNotDiscovered + // Grantless hello v2: no pair-grant fetch or mint precedes the dial. + // (Old Macs that still require a grant deny with `invalid-grant`; + // the engine parks until they update - list-auth Macs deploy first.) + let (admit, control) = try await IrxAdmission.performClient( + connection: connection, + journal: Self.journal + ) + // Credit the server-opened events lane now that admission holds. + await connection.raiseRemoteStreamCredit(bi: 0, uni: 4) + // Automatic path mode: authorize NAT traversal so iroh can upgrade + // this session off the relay make-before-break (direct/LAN paths). + if !Self.forceRelayOnly { + await connection.authorizeDirectPaths() } - return try await broker.issuePairGrant( - acceptorBindingID: acceptorBinding.bindingID, - acceptorEndpointIDHex: peerHex + return IrxClientSession( + connection: connection, + admit: admit, + control: control, + establishedAt: Date() ) } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift index ad1bdc35bb53..6d7bacb1121c 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift @@ -250,7 +250,7 @@ struct MobileIrohRuntimeCompositionTests { _ = try #require(readiness.completeFailure( revision: 1, accountID: "account-a", - error: CmxIrohTrustBrokerClientError.connectivity, + error: CmxIrohTrustBrokerClientError.connectivity(nil), retryAfterSeconds: nil, now: start )) diff --git a/ios/scripts/reload.sh b/ios/scripts/reload.sh index b9ac0fb2cf7a..9c03dc36cb41 100755 --- a/ios/scripts/reload.sh +++ b/ios/scripts/reload.sh @@ -19,6 +19,10 @@ queue (scripts/iphone-install-queue.sh) and auto-installs when the phone reconnects. Unless a simulator is named explicitly, the simulator leg uses the tag's own isolated device ("cmux-dev-"), created on demand. +When a trusted phone leg is enabled, the simulator app is installed but not +launched. The simulator's agent auto-pair would replace the phone's personal +Mac pairing; use --simulator-only for a connected simulator run. + Every device build requires the same-tag Mac dev build (the iOS app is unusable without its Mac); when it is missing, the Mac tag is built first, and the reload refuses to ship a phone-only build if that fails. @@ -80,6 +84,12 @@ ALLOW_DEVICE_REGISTRATION=0 NO_SIGN_IN=0 NO_ATTACH=0 NO_SETUP=0 +# A combined phone + simulator reload uses different auth profiles for each +# surface. Launching the simulator first would sign the shared tagged Mac into +# the agent account and invalidate the physical phone's personal pairing. Keep +# the simulator installed but unlaunched during a trusted phone reload; use +# --simulator-only for a connected simulator run. +SIMULATOR_LAUNCH=1 # Disable AArch64 GlobalISel codegen for this build. Xcode 26's Swift frontend # can miscompile under -O/wholemodule on the GlobalISel path, surfacing as bogus # "undefined symbol: _abort/_free/..." link failures. Mirrors scripts/reload.sh. @@ -769,7 +779,7 @@ PY xcrun simctl boot "$SIM_ID" >/dev/null 2>&1 || true xcrun simctl install "$SIM_ID" "$APP_PATH" - if [[ "$LAUNCH" -eq 1 ]]; then + if [[ "$LAUNCH" -eq 1 && "$SIMULATOR_LAUNCH" -eq 1 ]]; then xcrun simctl terminate "$SIM_ID" "$BUNDLE_ID" >/dev/null 2>&1 || true if [[ "$NO_SETUP" -eq 1 || "$NO_SIGN_IN" -eq 1 ]]; then xcrun simctl launch "$SIM_ID" "$BUNDLE_ID" >/dev/null @@ -778,6 +788,8 @@ PY echo "error: repair the tagged Mac/Iroh route, or pass --no-attach, --no-sign-in, or --no-setup explicitly" >&2 return 1 fi + elif [[ "$LAUNCH" -eq 1 ]]; then + echo "==> simulator installed but not launched because the trusted phone reload owns the tagged Mac pairing" fi cat < iOS reload starting (tag: $TAG)" +if [[ "$RELOAD_DEVICE" -eq 1 && "$LAUNCH" -eq 1 && "$NO_SETUP" -eq 0 && "$NO_SIGN_IN" -eq 0 && "$NO_ATTACH" -eq 0 ]]; then + SIMULATOR_LAUNCH=0 + echo "==> combined phone reload will install but not launch the simulator to preserve the personal Mac pairing" +fi + if [[ "$RELOAD_SIMULATOR" -eq 1 ]]; then reload_simulator fi diff --git a/schemas/control-plane/ack.schema.json b/schemas/control-plane/ack.schema.json new file mode 100644 index 000000000000..8cc918399720 --- /dev/null +++ b/schemas/control-plane/ack.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlAck", + "type": "object", + "required": [ + "v", + "type", + "rev", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "ack" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "appliedAt": { + "type": "string", + "format": "date-time", + "description": "optional client stamp of when the acked revision was applied" + } + } + }, + "rev": { + "type": "integer", + "minimum": 0, + "description": "directory/hint revision the client has applied; stops the server's retry ladder for revisions up to and including it" + } + } +} diff --git a/schemas/control-plane/control-error.schema.json b/schemas/control-plane/control-error.schema.json new file mode 100644 index 000000000000..1811cb13ace9 --- /dev/null +++ b/schemas/control-plane/control-error.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlError", + "type": "object", + "required": [ + "v", + "type", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "error" + }, + "payload": { + "type": "object", + "required": [ + "code", + "message", + "retryable" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean" + } + } + } + } +} diff --git a/schemas/control-plane/directory.schema.json b/schemas/control-plane/directory.schema.json new file mode 100644 index 000000000000..d85a3a1fff18 --- /dev/null +++ b/schemas/control-plane/directory.schema.json @@ -0,0 +1,186 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlDirectory", + "type": "object", + "required": [ + "v", + "type", + "rev", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "directory" + }, + "payload": { + "type": "object", + "required": [ + "routeContractVersion", + "bindings", + "relayFleet", + "grantVerificationKeys", + "issuedAt", + "ttlSeconds" + ], + "additionalProperties": false, + "properties": { + "routeContractVersion": { + "type": "integer" + }, + "bindings": { + "type": "array", + "items": { + "type": "object", + "required": [ + "bindingId", + "endpointId", + "clientNamespace", + "revoked" + ], + "additionalProperties": false, + "properties": { + "bindingId": { + "type": "string" + }, + "endpointId": { + "type": "string" + }, + "deviceId": { + "type": [ + "string", + "null" + ] + }, + "instanceTag": { + "type": [ + "string", + "null" + ] + }, + "clientNamespace": { + "type": "string" + }, + "homeRelayUrl": { + "type": [ + "string", + "null" + ] + }, + "updatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "active", + "seeded", + "stale", + "retired", + "suspended", + "pending", + "superseded" + ], + "description": "device lifecycle state from the account overlay; a binding never confirmed by its own hello stays seeded" + }, + "revoked": { + "type": "boolean", + "default": false, + "description": "authorization kill switch, orthogonal to status; peers must deny P2P admission to a revoked device" + }, + "appVersion": { + "type": "string" + }, + "releaseTrack": { + "type": "string", + "enum": [ + "nightly", + "stable", + "internal", + "beta", + "appstore", + "dev" + ] + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "lastConfirmedAt": { + "type": "string", + "format": "date-time", + "description": "when this device last confirmed itself over its own control-plane hello" + } + } + } + }, + "relayFleet": { + "type": "array", + "items": { + "type": "string" + } + }, + "grantVerificationKeys": { + "type": "array", + "items": { + "type": "object", + "required": [ + "keyId", + "alg", + "publicKey" + ], + "additionalProperties": false, + "properties": { + "keyId": { + "type": "string" + }, + "alg": { + "type": "string" + }, + "publicKey": { + "type": "string" + } + } + } + }, + "issuedAt": { + "type": "string", + "format": "date-time", + "description": "server stamp when this directory was issued; anchor of the trust lease" + }, + "ttlSeconds": { + "type": "integer", + "description": "trust lease duration; clients treat the directory as stale once issuedAt + ttlSeconds passes without a re-stamp" + }, + "minimumSupportedVersion": { + "type": "object", + "additionalProperties": false, + "description": "per-platform app-version floors; clients below the floor must update before participating", + "properties": { + "mac": { + "type": "string" + }, + "ios": { + "type": "string" + } + } + } + } + }, + "rev": { + "type": "integer", + "minimum": 0, + "description": "monotonic account route revision this fact reflects" + } + } +} diff --git a/schemas/control-plane/fixtures/ack.json b/schemas/control-plane/fixtures/ack.json new file mode 100644 index 000000000000..984bb6456b65 --- /dev/null +++ b/schemas/control-plane/fixtures/ack.json @@ -0,0 +1,8 @@ +{ + "v": 1, + "type": "ack", + "rev": 42, + "payload": { + "appliedAt": "2026-08-27T02:49:46Z" + } +} diff --git a/schemas/control-plane/fixtures/control-error.json b/schemas/control-plane/fixtures/control-error.json new file mode 100644 index 000000000000..d22e0fd7ca11 --- /dev/null +++ b/schemas/control-plane/fixtures/control-error.json @@ -0,0 +1,9 @@ +{ + "v": 1, + "type": "error", + "payload": { + "code": "mint_upstream_unavailable", + "message": "relay token mint failed upstream", + "retryable": true + } +} diff --git a/schemas/control-plane/fixtures/directory.json b/schemas/control-plane/fixtures/directory.json new file mode 100644 index 000000000000..2f7d194e2008 --- /dev/null +++ b/schemas/control-plane/fixtures/directory.json @@ -0,0 +1,53 @@ +{ + "v": 1, + "type": "directory", + "rev": 42, + "payload": { + "routeContractVersion": 3, + "bindings": [ + { + "bindingId": "611ffbbb-9f60-4601-ba39-4c241b900497", + "endpointId": "0fbffe130b96", + "deviceId": "77116c35", + "instanceTag": "irx", + "clientNamespace": "irx", + "homeRelayUrl": "https://usw1.relay.cmux.dev/", + "updatedAt": "2026-08-27T02:49:45Z", + "status": "active", + "revoked": false, + "appVersion": "0.31.4", + "releaseTrack": "internal", + "capabilities": ["cmux.irx.v1"], + "lastConfirmedAt": "2026-08-27T02:49:40Z" + }, + { + "bindingId": "e1b78ec4-7b2e-4077-88a4-ec4da794a9c6", + "endpointId": "8de4b1c22a10", + "deviceId": null, + "instanceTag": null, + "clientNamespace": "irx", + "homeRelayUrl": null, + "updatedAt": null, + "status": "seeded", + "revoked": true + } + ], + "relayFleet": [ + "https://usw1.relay.cmux.dev/", + "https://usc1.relay.cmux.dev/" + ], + "grantVerificationKeys": [ + { + "keyId": "k1", + "alg": "EdDSA", + "publicKey": "MCow..." + } + ], + "issuedAt": "2026-08-27T02:49:45Z", + "ttlSeconds": 86400, + "minimumSupportedVersion": { + "mac": "0.30.0", + "ios": "1.4.0" + } + } +} diff --git a/schemas/control-plane/fixtures/hello-ack.json b/schemas/control-plane/fixtures/hello-ack.json new file mode 100644 index 000000000000..c0bd31e1920f --- /dev/null +++ b/schemas/control-plane/fixtures/hello-ack.json @@ -0,0 +1,13 @@ +{ + "v": 1, + "type": "hello_ack", + "payload": { + "sessionId": "b2a7", + "resumedFromRev": 41, + "serverCapabilities": ["cmux.ctl.listv2", "cmux.ctl.ack", "cmux.ctl.revocation"], + "minimumSupportedVersion": { + "mac": "0.30.0", + "ios": "1.4.0" + } + } +} diff --git a/schemas/control-plane/fixtures/hello.json b/schemas/control-plane/fixtures/hello.json new file mode 100644 index 000000000000..026ea3ccef9e --- /dev/null +++ b/schemas/control-plane/fixtures/hello.json @@ -0,0 +1,14 @@ +{ + "v": 1, + "type": "hello", + "payload": { + "endpointId": "8de4b1c22a10", + "haveRev": 41, + "wantPasses": true, + "deviceId": "77116c35-0000-4000-8000-000000000002", + "platform": "ios", + "appVersion": "1.5.2", + "releaseTrack": "beta", + "capabilities": ["cmux.irx.v1", "cmux.ctl.ack"] + } +} diff --git a/schemas/control-plane/fixtures/hint-update.json b/schemas/control-plane/fixtures/hint-update.json new file mode 100644 index 000000000000..f73c4e9c640f --- /dev/null +++ b/schemas/control-plane/fixtures/hint-update.json @@ -0,0 +1,10 @@ +{ + "v": 1, + "type": "hint_update", + "rev": 43, + "payload": { + "endpointId": "0fbffe130b96", + "homeRelayUrl": "https://use4.relay.cmux.dev/", + "updatedAt": "2026-08-27T03:12:00Z" + } +} diff --git a/schemas/control-plane/fixtures/mint-request.json b/schemas/control-plane/fixtures/mint-request.json new file mode 100644 index 000000000000..61086c938525 --- /dev/null +++ b/schemas/control-plane/fixtures/mint-request.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "type": "mint_request", + "payload": { + "endpointId": "8de4b1c22a10", + "proof": { + "bindingId": "e1b78ec4-7b2e-4077-88a4-ec4da794a9c6", + "timestamp": "2026-08-27T03:05:47Z", + "signature": "sig64==" + } + } +} diff --git a/schemas/control-plane/fixtures/publish-hint.json b/schemas/control-plane/fixtures/publish-hint.json new file mode 100644 index 000000000000..23175ea31b38 --- /dev/null +++ b/schemas/control-plane/fixtures/publish-hint.json @@ -0,0 +1,13 @@ +{ + "v": 1, + "type": "publish_hint", + "payload": { + "endpointId": "0fbffe130b96", + "homeRelayUrl": "https://usw1.relay.cmux.dev/", + "proof": { + "bindingId": "611ffbbb-9f60-4601-ba39-4c241b900497", + "timestamp": "2026-08-27T03:05:47Z", + "signature": "sig64==" + } + } +} diff --git a/schemas/control-plane/fixtures/relay-passes.json b/schemas/control-plane/fixtures/relay-passes.json new file mode 100644 index 000000000000..e642d36b1c1e --- /dev/null +++ b/schemas/control-plane/fixtures/relay-passes.json @@ -0,0 +1,17 @@ +{ + "v": 1, + "type": "relay_passes", + "rev": 42, + "payload": { + "endpointId": "0fbffe130b96", + "passes": [ + { + "relayUrl": "https://usw1.relay.cmux.dev/", + "token": "eyJ...", + "expiresAt": "2026-08-27T03:10:48Z", + "generation": 7, + "refreshAfter": "2026-08-27T03:09:48Z" + } + ] + } +} diff --git a/schemas/control-plane/fixtures/snapshot-complete.json b/schemas/control-plane/fixtures/snapshot-complete.json new file mode 100644 index 000000000000..30f17b6266bb --- /dev/null +++ b/schemas/control-plane/fixtures/snapshot-complete.json @@ -0,0 +1,8 @@ +{ + "v": 1, + "type": "snapshot_complete", + "rev": 42, + "payload": { + "issuedAt": "2026-08-27T02:49:45Z" + } +} diff --git a/schemas/control-plane/hello-ack.schema.json b/schemas/control-plane/hello-ack.schema.json new file mode 100644 index 000000000000..f5508eaaa273 --- /dev/null +++ b/schemas/control-plane/hello-ack.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlHelloAck", + "type": "object", + "required": [ + "v", + "type", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "hello_ack" + }, + "payload": { + "type": "object", + "required": [ + "sessionId" + ], + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "string" + }, + "resumedFromRev": { + "type": [ + "integer", + "null" + ], + "description": "rev the server resumed the delta stream from; null means full snapshot follows" + }, + "serverCapabilities": { + "type": "array", + "items": { + "type": "string" + }, + "description": "control-plane features this server supports (list overlay, ack tracking, revocation)" + }, + "minimumSupportedVersion": { + "type": "object", + "additionalProperties": false, + "description": "echo of the directory's per-platform version floors so clients get them before the directory body", + "properties": { + "mac": { + "type": "string" + }, + "ios": { + "type": "string" + } + } + } + } + } + } +} diff --git a/schemas/control-plane/hello.schema.json b/schemas/control-plane/hello.schema.json new file mode 100644 index 000000000000..1227cdaf6d73 --- /dev/null +++ b/schemas/control-plane/hello.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlHello", + "type": "object", + "required": [ + "v", + "type", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "hello" + }, + "payload": { + "type": "object", + "required": [ + "endpointId", + "wantPasses" + ], + "additionalProperties": false, + "properties": { + "endpointId": { + "type": "string" + }, + "haveRev": { + "type": [ + "integer", + "null" + ], + "description": "highest rev this client has on disk; server streams deltas after it, or a full snapshot when null/too old" + }, + "wantPasses": { + "type": "boolean" + }, + "deviceId": { + "type": "string", + "description": "optional client self-identification; presence of any client-info field confirms the device into the account overlay" + }, + "platform": { + "type": "string", + "enum": [ + "mac", + "ios" + ] + }, + "appVersion": { + "type": "string" + }, + "releaseTrack": { + "type": "string", + "enum": [ + "nightly", + "stable", + "internal", + "beta", + "appstore", + "dev" + ] + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/schemas/control-plane/hint-update.schema.json b/schemas/control-plane/hint-update.schema.json new file mode 100644 index 000000000000..1ddc36fd2fa8 --- /dev/null +++ b/schemas/control-plane/hint-update.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlHintUpdate", + "type": "object", + "required": [ + "v", + "type", + "rev", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "hint_update" + }, + "payload": { + "type": "object", + "required": [ + "endpointId", + "homeRelayUrl" + ], + "additionalProperties": false, + "properties": { + "endpointId": { + "type": "string" + }, + "homeRelayUrl": { + "type": "string" + }, + "updatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "rev": { + "type": "integer", + "minimum": 0, + "description": "monotonic account route revision this fact reflects" + } + } +} diff --git a/schemas/control-plane/mint-request.schema.json b/schemas/control-plane/mint-request.schema.json new file mode 100644 index 000000000000..750b34d3fe11 --- /dev/null +++ b/schemas/control-plane/mint-request.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlMintRequest", + "type": "object", + "required": [ + "v", + "type", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "mint_request" + }, + "payload": { + "type": "object", + "required": [ + "endpointId" + ], + "additionalProperties": false, + "properties": { + "endpointId": { + "type": "string" + }, + "proof": { + "type": "object", + "description": "Optional signed identity assertion (reserved for the source-of-truth migration; phase A authorizes via the bearer-authenticated socket and confirms hints by re-fetching discovery)", + "required": [ + "bindingId", + "timestamp", + "signature" + ], + "additionalProperties": false, + "properties": { + "bindingId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "description": "RFC3339 issue time; server enforces freshness window" + }, + "signature": { + "type": "string", + "description": "base64 Ed25519 signature by the endpoint key" + } + } + } + } + } + } +} diff --git a/schemas/control-plane/publish-hint.schema.json b/schemas/control-plane/publish-hint.schema.json new file mode 100644 index 000000000000..67260fa81b9f --- /dev/null +++ b/schemas/control-plane/publish-hint.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlPublishHint", + "type": "object", + "required": [ + "v", + "type", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "publish_hint" + }, + "payload": { + "type": "object", + "required": [ + "endpointId", + "homeRelayUrl" + ], + "additionalProperties": false, + "properties": { + "endpointId": { + "type": "string" + }, + "homeRelayUrl": { + "type": "string" + }, + "proof": { + "type": "object", + "description": "Optional signed identity assertion (reserved for the source-of-truth migration; phase A authorizes via the bearer-authenticated socket and confirms hints by re-fetching discovery)", + "required": [ + "bindingId", + "timestamp", + "signature" + ], + "additionalProperties": false, + "properties": { + "bindingId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "description": "RFC3339 issue time; server enforces freshness window" + }, + "signature": { + "type": "string", + "description": "base64 Ed25519 signature by the endpoint key" + } + } + } + } + } + } +} diff --git a/schemas/control-plane/relay-passes.schema.json b/schemas/control-plane/relay-passes.schema.json new file mode 100644 index 000000000000..865693e4b831 --- /dev/null +++ b/schemas/control-plane/relay-passes.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlRelayPasses", + "type": "object", + "required": [ + "v", + "type", + "rev", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "relay_passes" + }, + "payload": { + "type": "object", + "required": [ + "endpointId", + "passes" + ], + "additionalProperties": false, + "properties": { + "endpointId": { + "type": "string" + }, + "passes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "relayUrl", + "token", + "expiresAt", + "refreshAfter", + "generation" + ], + "additionalProperties": false, + "properties": { + "relayUrl": { + "type": "string" + }, + "token": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "generation": { + "type": "integer" + }, + "refreshAfter": { + "type": "string", + "format": "date-time", + "description": "server-driven early-refresh point (expiry minus margin)" + } + } + } + } + } + }, + "rev": { + "type": "integer", + "minimum": 0, + "description": "monotonic account route revision this fact reflects" + } + } +} diff --git a/schemas/control-plane/snapshot-complete.schema.json b/schemas/control-plane/snapshot-complete.schema.json new file mode 100644 index 000000000000..07edb4cfa20d --- /dev/null +++ b/schemas/control-plane/snapshot-complete.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CtlSnapshotComplete", + "type": "object", + "required": [ + "v", + "type", + "rev", + "payload" + ], + "additionalProperties": false, + "properties": { + "v": { + "type": "integer", + "description": "control-plane protocol version, 1" + }, + "type": { + "const": "snapshot_complete" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "issuedAt": { + "type": "string", + "format": "date-time", + "description": "server freshness re-stamp; when a hello's haveRev already matches head this frame alone re-arms the directory trust lease without resending the body" + } + } + }, + "rev": { + "type": "integer", + "minimum": 0, + "description": "monotonic account route revision this fact reflects" + } + } +} diff --git a/scripts/check-control-plane-types.sh b/scripts/check-control-plane-types.sh new file mode 100755 index 000000000000..a8f2be3513cb --- /dev/null +++ b/scripts/check-control-plane-types.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# CI guard: the committed control-plane wire types must be exactly what +# scripts/gen-control-plane-types.sh produces from schemas/control-plane/. +# Fails on drift (hand-edited generated file, or schema change without regen). +set -euo pipefail + +cd "$(dirname "$0")/.." + +SWIFT_COMMITTED="Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/CtlWireModels.swift" +TS_COMMITTED="workers/presence/src/generated/controlPlane.ts" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +./scripts/gen-control-plane-types.sh "$TMP/CtlWireModels.swift" "$TMP/controlPlane.ts" >/dev/null + +fail=0 +diff -u "$SWIFT_COMMITTED" "$TMP/CtlWireModels.swift" || fail=1 +diff -u "$TS_COMMITTED" "$TMP/controlPlane.ts" || fail=1 + +if [ "$fail" -ne 0 ]; then + echo "control-plane types are stale or hand-edited; run ./scripts/gen-control-plane-types.sh" >&2 + exit 1 +fi +echo "control-plane types OK" diff --git a/scripts/gen-control-plane-types.sh b/scripts/gen-control-plane-types.sh new file mode 100755 index 000000000000..36d0a9f539c0 --- /dev/null +++ b/scripts/gen-control-plane-types.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Generate the control-plane wire types from schemas/control-plane/*.schema.json. +# +# The schemas are the ONLY hand-editable source. Both outputs are committed; +# scripts/check-control-plane-types.sh regenerates and fails CI on any diff, +# so the generated files can never drift from the schemas or be hand-edited. +# +# Determinism: quicktype is pinned to an exact version and all flags live +# here. Same schemas + same version + same flags = byte-identical output. +set -euo pipefail + +cd "$(dirname "$0")/.." + +QUICKTYPE_VERSION=26.0.0 +QT=(bunx "quicktype@${QUICKTYPE_VERSION}") +if ! command -v bunx >/dev/null 2>&1; then + QT=(npx --yes "quicktype@${QUICKTYPE_VERSION}") +fi + +SCHEMAS=(schemas/control-plane/*.schema.json) +SWIFT_OUT="${1:-Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/CtlWireModels.swift}" +TS_OUT="${2:-workers/presence/src/generated/controlPlane.ts}" + +mkdir -p "$(dirname "$SWIFT_OUT")" "$(dirname "$TS_OUT")" + +"${QT[@]}" --src-lang schema "${SCHEMAS[@]}" \ + --lang swift \ + --struct-or-class struct \ + --access-level public \ + --protocol equatable \ + --no-initializers \ + --out "$SWIFT_OUT" + +# CmuxIrxTransport builds with Swift 6 access-level imports: public API using +# Foundation types requires `public import Foundation`. +perl -pi -e 's/^import Foundation$/public import Foundation/' "$SWIFT_OUT" + +"${QT[@]}" --src-lang schema "${SCHEMAS[@]}" \ + --lang typescript \ + --just-types \ + --prefer-unions \ + --out "$TS_OUT" + +echo "generated: $SWIFT_OUT" +echo "generated: $TS_OUT" diff --git a/scripts/listauth-gates.py b/scripts/listauth-gates.py new file mode 100644 index 000000000000..ea6be155bfc7 --- /dev/null +++ b/scripts/listauth-gates.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""Acceptance gates for the device-list authorization architecture (listauth). + +Gates (see cmux-assets/irx-client-resilience/irx-sequence-diagram artifact): + 1. soak: 30 continuous engaged minutes, zero unexpected reconnects or + disconnects; rotations + list pushes cross without session + impact. --mode relay asserts every pong path is relay:*; + --mode direct asserts >=90% of pongs after the first minute + ride a non-relay (direct/LAN) path. + 2. cold: app terminate -> launch -> first admission admitted AND first + control-plane directory applied, each under --limit-ms + (default 2000) measured wall-to-wall. + 3. background: foreground another app for --minutes, then relaunch ours; + time from relaunch to admitted + fresh directory < limit. + +Shared evidence: journals ({ts, mono_ms, component, event, a_*} JSONL) from +/tmp/cmux-irx-journal-mac-.jsonl and the sim app container's +Documents/irx-journal.jsonl. Verdicts land in /-/. + +Usage: + python3 scripts/listauth-gates.py soak --tag --udid U --bundle-id B \ + --mode relay --minutes 30 [--out DIR] + python3 scripts/listauth-gates.py cold --tag --udid U --bundle-id B \ + --trials 3 [--limit-ms 2000] + python3 scripts/listauth-gates.py background --tag --udid U \ + --bundle-id B --minutes 30 [--limit-ms 2000] +""" + +import argparse +import datetime +import os +import hashlib +import json +import pathlib +import subprocess +import sys +import time + +parser = argparse.ArgumentParser() +parser.add_argument("gate", choices=["soak", "cold", "background"]) +parser.add_argument("--tag", required=True) +parser.add_argument("--udid", required=True) +parser.add_argument("--bundle-id", required=True) +parser.add_argument("--minutes", type=int, default=30) +parser.add_argument("--mode", choices=["relay", "direct"], default="relay") +parser.add_argument("--trials", type=int, default=3) +parser.add_argument("--limit-ms", type=int, default=2000) +parser.add_argument("--admitted-limit-ms", type=int, default=3000, + help="session-admitted bound (transport target); the " + "stated <2s gate is time-to-directory") +parser.add_argument("--out", default="/tmp/listauth-gates") +parser.add_argument("--no-input", action="store_true") +args = parser.parse_args() + +def signin_env(): + """Sim UITEST sign-in is per-launch; every launch needs the agent-profile + credentials injected or the app lands signed out (dev-env artifact, not a + product behavior).""" + env = dict(os.environ) + secrets = pathlib.Path.home() / ".secrets" / "cmuxterm-dev.env" + creds = {} + if secrets.exists(): + for line in secrets.read_text().splitlines(): + if "=" in line and not line.lstrip().startswith("#"): + k, _, v = line.partition("=") + creds[k.strip()] = v.strip().strip('"').strip("'") + email = creds.get("CMUX_UITEST_STACK_EMAIL", "") + password = creds.get("CMUX_UITEST_STACK_PASSWORD", "") + if email and password: + env["SIMCTL_CHILD_CMUX_UITEST_STACK_EMAIL"] = email + env["SIMCTL_CHILD_CMUX_UITEST_STACK_PASSWORD"] = password + env["SIMCTL_CHILD_CMUX_UITEST_MOCK_DATA"] = "0" + env["SIMCTL_CHILD_CMUX_DEV_AUTH_REPLACE_SESSION"] = "0" + return env + + +def launch_app(): + subprocess.run(["xcrun", "simctl", "launch", args.udid, args.bundle_id], + capture_output=True, env=signin_env()) + + +stamp = time.strftime("%Y%m%d-%H%M%S") +round_dir = pathlib.Path(args.out) / f"{args.gate}-{args.mode}-{stamp}" +round_dir.mkdir(parents=True, exist_ok=True) +mac_journal_path = pathlib.Path(f"/tmp/cmux-irx-journal-mac-{args.tag}.jsonl") + + +def sim_journal_path(): + try: + container = subprocess.check_output( + ["xcrun", "simctl", "get_app_container", args.udid, args.bundle_id, "data"], + text=True).strip() + return pathlib.Path(container) / "Documents" / "irx-journal.jsonl" + except subprocess.CalledProcessError: + return None + + +def read_events(path, since_index): + if path is None or not path.exists(): + return [], since_index + lines = path.read_text(errors="replace").splitlines() + events = [] + for line in lines[since_index:]: + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + return events, len(lines) + + +def wall_of(event): + try: + ts = event.get("ts", "") + return datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +def screenshot(label): + out = round_dir / f"shot-{label}.png" + subprocess.run(["xcrun", "simctl", "io", args.udid, "screenshot", str(out)], + capture_output=True) + return hashlib.sha256(out.read_bytes()).hexdigest() if out.exists() else None + + +def type_input(text): + if args.no_input: + return False + typed = subprocess.run(["idb", "ui", "text", "--udid", args.udid, text], + capture_output=True, text=True, timeout=30) + if typed.returncode != 0: + return False + subprocess.run(["idb", "ui", "key", "--udid", args.udid, "40"], + capture_output=True, text=True, timeout=30) + return True + + +def finish(name, failures, extra): + verdict = "PASS" if not failures else "FAIL" + result = {"gate": name, "mode": args.mode, "verdict": verdict, + "limit_ms": args.limit_ms, "failures": failures, **extra} + (round_dir / "verdict.json").write_text(json.dumps(result, indent=2, default=str)) + if mac_journal_path.exists(): + (round_dir / "mac-journal.jsonl").write_text( + mac_journal_path.read_text(errors="replace")) + sp = sim_journal_path() + if sp and sp.exists(): + (round_dir / "sim-journal.jsonl").write_text(sp.read_text(errors="replace")) + print(f"[gates] {name} VERDICT: {verdict}") + print(json.dumps(result, indent=2, default=str)) + sys.exit(0 if verdict == "PASS" else 1) + + +CLIENT_FATAL = { + ("engine", "session-ended"), + ("engine", "dial-denied"), + ("engine", "auto-redial"), + ("engine", "auto-redial-suppressed"), + ("keepalive", "timeout"), + ("endpoint", "closed-unexpectedly"), + ("admission", "denied-or-timeout"), + ("client-events", "lane-missing"), +} +MAC_FATAL_SESSION_SCOPED = { + ("host-runtime", "connection-exit"), + ("registry", "superseded"), +} +MAC_FATAL = { + ("endpoint", "closed-unexpectedly"), + ("endpoint", "relay-credential-rotation-failed"), + ("host-events", "writer-reset"), + ("host-terminal", "cursor-gap"), +} + + +def wait_for_events_after(t_launch, wanted, timeout_s): + """Watch the sim journal for the FIRST occurrence of each wanted + (component,event) after launch. Timing anchor: the signed-in auth-gate + event of the SAME launch (the per-launch simulator sign-in is a dev-env + step a real signed-in user never pays), falling back to composition + start (mono 0). Returns {key: {setup_ms, mono_ms, wall_ms}}.""" + sp = sim_journal_path() + _, idx = read_events(sp, 0) + found = {} + anchor_mono = 0 + deadline = time.time() + timeout_s + while time.time() < deadline and len(found) < len(wanted): + time.sleep(0.2) + events, idx = read_events(sp, idx) + for event in events: + wall = wall_of(event) + if wall is None or wall < t_launch - 0.5: + continue + key = (event.get("component"), event.get("event")) + if key == ("client-runtime", "auth-gate-signed-in") or ( + key == ("client-runtime", "auth-gate-identity") + and event.get("a_signed_in") == "true" + ): + anchor_mono = int(event.get("mono_ms", 0)) + if key in wanted and key not in found: + found[key] = {"mono_ms": int(event.get("mono_ms", -1)), + "wall_ms": int((wall - t_launch) * 1000)} + for hit in found.values(): + hit["setup_ms"] = max(0, hit["mono_ms"] - anchor_mono) + return found + + +def gate_cold(): + trials = [] + failures = [] + wanted = {("admission", "admitted"), ("control-plane", "directory")} + for trial in range(args.trials): + subprocess.run(["xcrun", "simctl", "terminate", args.udid, args.bundle_id], + capture_output=True) + time.sleep(3) + t_launch = time.time() + launch_app() + found = wait_for_events_after(t_launch, wanted, timeout_s=60) + row = {"trial": trial, + "admitted": found.get(("admission", "admitted")), + "directory": found.get(("control-plane", "directory"))} + trials.append(row) + for key, label in ((("admission", "admitted"), "admitted"), + (("control-plane", "directory"), "directory")): + hit = found.get(key) + if hit is None: + failures.append({"trial": trial, "why": f"{label} never observed"}) + else: + limit = args.limit_ms if label == "directory" else args.admitted_limit_ms + if hit["setup_ms"] > limit: + failures.append({"trial": trial, + "why": f"{label} {hit['setup_ms']}ms > {limit}ms"}) + print(f"[gates] cold trial {trial}: {row}") + time.sleep(5) + finish("cold", failures, {"trials": trials}) + + +def gate_background(): + # Push the app to background by foregrounding Settings, hold, relaunch. + subprocess.run(["xcrun", "simctl", "launch", args.udid, "com.apple.Preferences"], + capture_output=True) + print(f"[gates] app backgrounded; holding {args.minutes} minutes") + time.sleep(args.minutes * 60) + t_launch = time.time() + launch_app() + wanted = {("admission", "admitted"), ("control-plane", "directory")} + found = wait_for_events_after(t_launch, wanted, timeout_s=60) + failures = [] + row = {"admitted": found.get(("admission", "admitted")), + "directory": found.get(("control-plane", "directory"))} + for key, label in ((("admission", "admitted"), "admitted"), + (("control-plane", "directory"), "directory")): + hit = found.get(key) + if hit is None: + failures.append({"why": f"{label} never observed after foreground"}) + else: + # Resume keeps the process alive: no sign-in anchor fires, so the + # foreground-relative WALL delta is the metric (host and sim share + # one clock). setup_ms would be total process uptime here. + limit = args.limit_ms if label == "directory" else args.admitted_limit_ms + if hit["wall_ms"] > limit: + failures.append({"why": f"{label} {hit['wall_ms']}ms > {limit}ms"}) + finish("background", failures, {"return": row, + "background_minutes": args.minutes}) + + +def gate_soak(): + sim_path = sim_journal_path() + print(f"[gates] round dir: {round_dir}") + print(f"[gates] mac journal exists={mac_journal_path.exists()}") + print(f"[gates] sim journal exists={sim_path.exists() if sim_path else False}") + _, mac_index = read_events(mac_journal_path, 0) + pre_client, sim_index = read_events(sim_path, 0) + + establish_ms = None + for event in reversed(pre_client): + if (event.get("component"), event.get("event")) == ("admission", "admitted"): + try: + establish_ms = int(event.get("a_elapsed_ms", "999999")) + except ValueError: + establish_ms = None + break + + failures = [] + focus_sessions = set() + obs = {"client_pongs": 0, "relay_pongs": 0, "direct_pongs": 0, + "client_rotations": 0, "mac_rotations": 0, "list_pushes": 0, + "acks_sent": 0, "screenshot_changes": 0, "screenshot_samples": 0, + "inputs_typed": 0, "mint_failures": 0, "max_pong_gap_s": 0.0} + t0 = time.time() + deadline = t0 + args.minutes * 60 + last_shot = screenshot("t0") + last_pong_wall = time.time() + sample = 0 + + while time.time() < deadline: + time.sleep(10) + sample += 1 + now = time.time() + client_events, sim_index = read_events(sim_path, sim_index) + mac_events, mac_index = read_events(mac_journal_path, mac_index) + + for event in client_events: + key = (event.get("component"), event.get("event")) + if key == ("admission", "admitted") and event.get("a_session"): + focus_sessions.add(event["a_session"]) + if key in CLIENT_FATAL: + failures.append({"side": "client", "at_s": int(now - t0), "event": event}) + if key == ("keepalive", "pong"): + obs["client_pongs"] += 1 + last_pong_wall = now + path = event.get("a_path", "") + if path.startswith("relay:"): + obs["relay_pongs"] += 1 + else: + obs["direct_pongs"] += 1 + if args.mode == "relay" and not path.startswith("relay:"): + failures.append({"side": "client", "at_s": int(now - t0), + "event": event, "why": "non-relay path in relay mode"}) + if key == ("keepalive", "miss"): + obs["keepalive_misses"] = obs.get("keepalive_misses", 0) + 1 + if key == ("endpoint", "relay-credential-rotated"): + obs["client_rotations"] += 1 + if key == ("control-plane", "directory"): + obs["list_pushes"] += 1 + if key == ("control-plane", "acked"): + obs["acks_sent"] += 1 + if key == ("credential-autopilot", "mint-failed"): + obs["mint_failures"] += 1 + for event in mac_events: + key = (event.get("component"), event.get("event")) + if key in MAC_FATAL: + failures.append({"side": "mac", "at_s": int(now - t0), "event": event}) + if key in MAC_FATAL_SESSION_SCOPED and ( + event.get("a_session") in focus_sessions + or event.get("a_old_session") in focus_sessions + ): + failures.append({"side": "mac", "at_s": int(now - t0), "event": event}) + if key == ("endpoint", "relay-credential-rotated"): + obs["mac_rotations"] += 1 + + obs["max_pong_gap_s"] = max(obs["max_pong_gap_s"], now - last_pong_wall) + + if sample % 3 == 1 and type_input("date"): + obs["inputs_typed"] += 1 + if sample % 6 == 0: + shot = screenshot(f"t{int(now - t0)}") + obs["screenshot_samples"] += 1 + if shot and shot != last_shot: + obs["screenshot_changes"] += 1 + last_shot = shot + + state = "FAILURES: %d" % len(failures) if failures else ( + "OK pongs=%d (relay=%d direct=%d) rot c/m=%d/%d list=%d" % ( + obs["client_pongs"], obs["relay_pongs"], obs["direct_pongs"], + obs["client_rotations"], obs["mac_rotations"], obs["list_pushes"])) + print(f"[gates] +{int(now - t0)}s {state}") + + if establish_ms is None or establish_ms > args.limit_ms: + failures.append({"why": f"establishment {establish_ms}ms (need <={args.limit_ms})"}) + if args.minutes >= 25: + if obs["client_rotations"] < 3: + failures.append({"why": f"client rotations {obs['client_rotations']} < 3"}) + if obs["mac_rotations"] < 3: + failures.append({"why": f"mac rotations {obs['mac_rotations']} < 3"}) + if obs["max_pong_gap_s"] > 30: + failures.append({"why": f"pong gap {obs['max_pong_gap_s']:.0f}s > 30s"}) + if args.mode == "direct": + attributed = obs["relay_pongs"] + obs["direct_pongs"] + if attributed == 0 or obs["direct_pongs"] / max(attributed, 1) < 0.9: + failures.append({"why": f"direct mode but direct pongs " + f"{obs['direct_pongs']}/{attributed} < 90%"}) + if not args.no_input and obs["inputs_typed"] == 0: + failures.append({"why": "no input ever typed (engagement broken)"}) + if obs["screenshot_samples"] > 0 and obs["screenshot_changes"] == 0: + failures.append({"why": "screen never changed (stream frozen?)"}) + finish("soak", failures, {"minutes": args.minutes, + "establish_ms": establish_ms, "observations": obs}) + + +{"soak": gate_soak, "cold": gate_cold, "background": gate_background}[args.gate]() diff --git a/scripts/mobile-dev-launch.sh b/scripts/mobile-dev-launch.sh index 5b8105d8ab5c..1fbd327c2ce9 100755 --- a/scripts/mobile-dev-launch.sh +++ b/scripts/mobile-dev-launch.sh @@ -88,7 +88,11 @@ EXPECTED_ACCOUNT="" CHECK_AUTH_CONTRACT=0 ATTACH_TTL_SECONDS="${CMUX_ATTACH_TTL_SECONDS:-600}" ATTACH_MINT_MAX_ATTEMPTS="${CMUX_ATTACH_MINT_MAX_ATTEMPTS:-20}" -ATTACH_READY_TIMEOUT_SECONDS="${CMUX_ATTACH_READY_TIMEOUT_SECONDS:-15}" +# Stack session restore and the first control-plane snapshot can take longer +# than the attach handshake itself on a physical device. Keep the default +# bounded, but leave enough room for a cold auth bootstrap before declaring the +# install unusable; callers can still tighten or extend it explicitly. +ATTACH_READY_TIMEOUT_SECONDS="${CMUX_ATTACH_READY_TIMEOUT_SECONDS:-60}" usage() { sed -n '2,58p' "$0"; } diff --git a/web/services/vms/images/devbox/cmux-bashrc b/web/services/vms/images/devbox/cmux-bashrc index b239bae6e605..c8e9a0079135 100644 --- a/web/services/vms/images/devbox/cmux-bashrc +++ b/web/services/vms/images/devbox/cmux-bashrc @@ -16,6 +16,29 @@ if [ ! -f "$HOME/.bash_history" ] && [ -f /etc/cmux/seed-history ]; then cp /etc/cmux/seed-history "$HOME/.bash_history" 2>/dev/null || true fi +# Seed ble.sh's per-TERM tput caches (baked by the Dockerfile for the TERMs +# cmux uses) so no shell prints "ble/term.sh: updating tput cache ... done" +# into the pane before its first prompt. ble.sh uses ~/.cache only when it +# already exists (else it falls back to /cache.d/, seeded in the +# image). Per file, not per directory: a durable home carries the cache dir of +# an older image (other ble version dir, or entries stale against a newer +# lib/init-term.sh — ble.sh regenerates and prints unless the cache file is +# NEWER than that file), so copy each seed entry that is missing or stale. +# Plain cp (not -a) stamps now, which always beats init-term.sh. +if [ -d /etc/cmux/blesh-cache-seed/blesh ]; then + __cmux_cache_base="${XDG_CACHE_HOME:-$HOME/.cache}" + for __cmux_seed in /etc/cmux/blesh-cache-seed/blesh/*/term.*; do + [ -f "$__cmux_seed" ] || continue + __cmux_dst="$__cmux_cache_base/${__cmux_seed#/etc/cmux/blesh-cache-seed/}" + if [ ! -f "$__cmux_dst" ] || [ /usr/local/share/blesh/lib/init-term.sh -nt "$__cmux_dst" ]; then + mkdir -p "${__cmux_dst%/*}" 2>/dev/null \ + && cp "$__cmux_seed" "$__cmux_dst" 2>/dev/null \ + || true + fi + done + unset __cmux_cache_base __cmux_seed __cmux_dst +fi + if [ -f /usr/local/share/blesh/ble.sh ] && [ -z "${BLE_VERSION:-}" ]; then source /usr/local/share/blesh/ble.sh --noattach bleopt prompt_eol_mark= exec_errexit_mark= exec_elapsed_mark= exec_exit_mark= diff --git a/workers/presence/README.md b/workers/presence/README.md index ed42c08b6e97..ad8bcb1221a6 100644 --- a/workers/presence/README.md +++ b/workers/presence/README.md @@ -21,6 +21,7 @@ solo-account user id). | `/v1/presence/subscribe` | GET | WebSocket upgrade or SSE stream: `snapshot` first, then `online` / `offline` / `seen` events | | `/v1/connectivity/subscribe` | GET | quiet WebSocket isolated by the verified Stack user; carries only route-revision invalidations | | `/v1/connectivity/invalidate` | POST | backend-only publication of `{revision}` to every connected Mac and iPhone for the verified Stack user | +| `/v1/control/socket` | GET | account control-plane WebSocket (`AccountControlPlane` DO, one per verified Stack user): revisioned `directory` / `hint_update` / `relay_passes` / `snapshot_complete` facts per the frozen `schemas/control-plane/` contract | The heartbeat response returns `heartbeatIntervalMs` (15s) and `offlineTimeoutMs` (45s); hosts should follow the returned cadence rather than @@ -57,6 +58,29 @@ Publication also requires the server-only `CONNECTIVITY_INVALIDATION_SECRET`; a native client access token cannot forge a revision. +The control plane (`/v1/control/socket`) is the successor channel: instead of +a bare revision nudge, one `AccountControlPlane` Durable Object per verified +Stack user streams the facts themselves. On `hello` the DO replies `hello_ack` +and streams each fact as it becomes ready: `directory` (proxied server-side +from `GET api/devices/iroh` with the connection's own bearer token, one +immediate retry on connection-level failure), `relay_passes` when the hello +asked for them (proxied from `POST api/relay/token`, body `{"endpointId"}`), +then `snapshot_complete` carrying the account route revision. A `hello` whose +`haveRev` equals the current revision skips the directory body +(`resumedFromRev`). While sockets are connected a 60s alarm re-fetches +discovery and broadcasts `hint_update`/`directory` deltas; `publish_hint` is +an instant-propagation announcement fanned out to the account's other sockets +and confirmed against broker truth a few seconds later (phase A never writes +hints upstream — hint registration stays the Mac's own signed HTTPS flow). +Upstream failures produce `error` frames with `retryable`, never a dropped +socket; cached facts keep serving. The DO holds no credentials of its own: +every upstream call uses the connecting socket's bearer, stored per-socket and +deleted at close/expiry, with stream lifetime capped at token expiry like the +other subscribe routes. Wire contract: `schemas/control-plane/*.schema.json` +with generated types in `src/generated/controlPlane.ts` (regenerated by +`scripts/gen-control-plane-types.sh`, drift-guarded in CI). The Vercel origin +is the optional var `CMUX_WEB_BASE_URL` (default `https://cmux.com`). + ## Develop ```bash diff --git a/workers/presence/src/controlPlane.ts b/workers/presence/src/controlPlane.ts new file mode 100644 index 000000000000..503c0fe924c9 --- /dev/null +++ b/workers/presence/src/controlPlane.ts @@ -0,0 +1,1692 @@ +// Account control plane — pure core (no Workers APIs, bun-testable). +// +// One AccountControlPlane Durable Object per verified Stack user id serves a +// WebSocket over which a signed-in cmux device receives, as revisioned facts, +// everything it needs to connect to its Macs: the account directory (bindings +// + home-relay hints + grant verification keys + relay fleet), relay passes, +// and live hint updates. Phase A: the DO is a smart proxy over the existing +// Vercel broker HTTPS endpoints (GET api/devices/iroh, POST api/relay/token); +// it is NOT the source of truth. Wire contract: schemas/control-plane/*, with +// generated types in ./generated/controlPlane (frozen — never hand-edited). +// +// This module holds every piece of logic that does not need workerd: frame +// parsing/building, upstream response mapping, and the ControlPlaneCore state +// machine driven through narrow injected dependencies (storage, upstream +// fetch, sockets, clock, alarm). The thin Durable Object adapter lives in +// controlPlaneDo.ts. + +import type { + Binding, + CTLACK, + CTLDirectory, + CTLDirectoryPayload, + CTLError, + CTLHello, + CTLHelloACK, + CTLHelloPayload, + CTLHintUpdate, + CTLMintRequest, + CTLPublishHint, + CTLRelayPasses, + CTLSnapshotComplete, + FluffyProof, + GrantVerificationKey, + Pass, + PurpleMinimumSupportedVersion, + ReleaseTrack, + Status, +} from "./generated/controlPlane"; + +export const CONTROL_PROTOCOL_VERSION = 1; + +/** Mirrors MAX_CONNECTIVITY_SUBSCRIBERS_PER_ACCOUNT: one account's devices are + * few; a runaway client must not pin unbounded sockets on the account DO. */ +export const MAX_CONTROL_SUBSCRIBERS_PER_ACCOUNT = 32; + +/** Max bytes of an inbound WS message the DO will parse. Client-controlled + * input on a live DO, so bounded before JSON.parse (same rationale as the + * presence DO's MAX_SYNC_HELLO_BYTES). The largest legitimate client frame is + * a publish_hint with proof, well under 2 KiB. */ +export const MAX_CONTROL_MESSAGE_BYTES = 8 * 1024; + +/** While any socket is connected, the DO re-fetches discovery on this cadence + * (alarm-driven, hibernation-friendly) and broadcasts deltas. */ +export const CONTROL_REFRESH_INTERVAL_MS = 60_000; + +/** Application heartbeat for the hibernatable WebSocket. Workers exposes + * text/binary sends but no portable server-side RFC6455 ping method, so this + * lightweight frame keeps idle intermediaries from reaping the network path. + * It is a liveness signal, not an authentication or subscription deadline. */ +export const CONTROL_HEARTBEAT_TYPE = "ping" as const; + +/** A publish_hint is an ANNOUNCEMENT (phase A never writes hints to Vercel — + * hint registration upstream is a challenge + Ed25519-signed registration flow + * only the Mac itself can perform). The DO broadcasts the claim immediately, + * then confirms against broker truth this soon after. */ +export const HINT_CONFIRM_DELAY_MS = 3_000; + +/** When the initial directory fetch fails and there is no cache to serve, the + * socket stays snapshot-pending and the alarm retries this soon. */ +export const SNAPSHOT_RETRY_DELAY_MS = 5_000; + +/** Trust lease served with every directory (and re-stamped through + * snapshot_complete.issuedAt): clients treat the list as stale once + * issuedAt + ttlSeconds passes without a fresher stamp. */ +export const DIRECTORY_TTL_SECONDS = 86_400; + +/** Advertised in every hello_ack so clients can feature-gate on the server: + * device-list overlay, ack tracking, and account-owner revocation. */ +export const CONTROL_SERVER_CAPABILITIES: readonly string[] = [ + "cmux.ctl.listv2", + "cmux.ctl.ack", + "cmux.ctl.revocation", +]; + +/** Ack retry ladder: a socket that has not acked the newest broadcast revision + * gets the LATEST directory (never historical deltas) resent at these offsets, + * then hourly until it acks. Per socket, reset on ack, alarm-driven — no + * timers. */ +export const ACK_RETRY_LADDER_MS: readonly number[] = [5_000, 30_000, 120_000, 600_000]; +export const ACK_RETRY_STEADY_MS = 3_600_000; + +const MAX_ENDPOINT_ID_CHARS = 128; +const MAX_RELAY_URL_CHARS = 512; +/** Bounds for hello client info. Exceeding one skips the confirm-on-hello + * (the hello itself still proceeds); nothing oversized reaches storage. */ +const MAX_DEVICE_ID_CHARS = 128; +const MAX_APP_VERSION_CHARS = 64; +const MAX_CLIENT_CAPABILITIES = 32; +const MAX_CLIENT_CAPABILITY_CHARS = 64; + +// ---- Storage keys (all under the account DO's own storage) ---- + +/** Last account route revision this DO observed (upstream `revision`, or the + * local fallback counter when upstream omits it). */ +export const REV_KEY = "ctl:rev"; +/** Cached BrokerDirectoryPayload from the last successful discovery fetch. + * Broker truth only: publish_hint announcements are never folded in, and the + * DO-owned device overlay is joined in at directory build time, not here. */ +export const DIR_KEY = "ctl:dir"; +/** Per-endpoint relay-pass mint generation counter (`ctl:gen:`). + * The broker response carries no generation; passes minted in one batch share + * one monotonically increasing number so clients can order credential sets. */ +export const GEN_PREFIX = "ctl:gen:"; +/** Per-socket bearer token (`ctl:bearer:`), stored so upstream + * calls survive DO hibernation. Strictly per-socket for endpoint-bound calls + * (mint); deleted on close and on revocation. The control adapter keeps these + * credentials for the lifetime of its authenticated socket. */ +export const BEARER_PREFIX = "ctl:bearer:"; +/** Per-device authorization overlay (`ctl:dev:`): the DO-owned + * listv2 facts (status, revoked, version/track/capabilities, confirmation and + * ack watermarks) joined onto broker bindings at every directory build. Rows + * whose binding disappeared upstream are kept (so revocation survives a + * binding flap) but never emitted. */ +export const DEV_PREFIX = "ctl:dev:"; + +/** The stored shape under DEV_PREFIX. lastAckedRev is bookkeeping only and is + * never emitted in the directory. deviceId/clientNamespace are captured at + * confirm-on-hello so a confirmed device can still be emitted (synthesized) + * when the upstream discovery view omits it — e.g. a namespace-filtered + * broker view, or upstream registration lag. */ +export interface DeviceOverlay { + status: Status; + revoked: boolean; + appVersion?: string; + releaseTrack?: ReleaseTrack; + capabilities?: string[]; + lastConfirmedAt?: string; + lastAckedRev?: number; + deviceId?: string; + clientNamespace?: string; +} + +// The generated types annotate RFC3339 `format: date-time` fields as `Date`, +// but quicktype ran with --just-types (no converters): on the wire — and at +// runtime here — they are plain RFC3339 strings. This is the single cast site. +function wireDate(iso: string): Date { + return iso as unknown as Date; +} + +function rfc3339FromMs(ms: number): string { + return new Date(ms).toISOString(); +} + +// ---- Frame decoding (strict: mirrors additionalProperties:false) ---- + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + for (const key of required) { + if (!(key in value)) return false; + } + for (const key of Object.keys(value)) { + if (!required.includes(key) && !optional.includes(key)) return false; + } + return true; +} + +function isRev(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function validEnvelope( + value: Record, + type: string, + withRev: boolean, +): boolean { + const keys = withRev ? (["v", "type", "rev", "payload"] as const) : (["v", "type", "payload"] as const); + if (!hasOnlyKeys(value, keys)) return false; + if (value.v !== CONTROL_PROTOCOL_VERSION) return false; + if (value.type !== type) return false; + if (withRev && !isRev(value.rev)) return false; + return isObject(value.payload); +} + +const DEVICE_STATUSES: ReadonlySet = new Set([ + "active", + "seeded", + "stale", + "retired", + "suspended", + "pending", + "superseded", +]); + +const RELEASE_TRACKS: ReadonlySet = new Set([ + "nightly", + "stable", + "internal", + "beta", + "appstore", + "dev", +]); + +const CLIENT_PLATFORMS: ReadonlySet = new Set(["mac", "ios"]); + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function validMinimumSupportedVersion(value: unknown): boolean { + if (!isObject(value)) return false; + if (!hasOnlyKeys(value, [], ["mac", "ios"])) return false; + for (const key of ["mac", "ios"] as const) { + if (key in value && typeof value[key] !== "string") return false; + } + return true; +} + +function validProof(value: unknown): value is FluffyProof { + if (!isObject(value)) return false; + if (!hasOnlyKeys(value, ["bindingId", "timestamp", "signature"])) return false; + return typeof value.bindingId === "string" + && typeof value.timestamp === "string" + && typeof value.signature === "string"; +} + +function validBinding(value: unknown): value is Binding { + if (!isObject(value)) return false; + if (!hasOnlyKeys( + value, + ["bindingId", "endpointId", "clientNamespace", "revoked"], + [ + "deviceId", + "instanceTag", + "homeRelayUrl", + "updatedAt", + "status", + "appVersion", + "releaseTrack", + "capabilities", + "lastConfirmedAt", + ], + )) return false; + if (typeof value.bindingId !== "string") return false; + if (typeof value.endpointId !== "string") return false; + if (typeof value.clientNamespace !== "string") return false; + if (typeof value.revoked !== "boolean") return false; + for (const key of ["deviceId", "instanceTag", "homeRelayUrl", "updatedAt"] as const) { + if (key in value && value[key] !== null && typeof value[key] !== "string") return false; + } + if ("status" in value + && !(typeof value.status === "string" && DEVICE_STATUSES.has(value.status))) return false; + if ("appVersion" in value && typeof value.appVersion !== "string") return false; + if ("releaseTrack" in value + && !(typeof value.releaseTrack === "string" && RELEASE_TRACKS.has(value.releaseTrack))) return false; + if ("capabilities" in value && !isStringArray(value.capabilities)) return false; + if ("lastConfirmedAt" in value && typeof value.lastConfirmedAt !== "string") return false; + return true; +} + +function validGrantKey(value: unknown): value is GrantVerificationKey { + if (!isObject(value)) return false; + if (!hasOnlyKeys(value, ["keyId", "alg", "publicKey"])) return false; + return typeof value.keyId === "string" + && typeof value.alg === "string" + && typeof value.publicKey === "string"; +} + +function validPass(value: unknown): value is Pass { + if (!isObject(value)) return false; + if (!hasOnlyKeys(value, ["relayUrl", "token", "expiresAt", "refreshAfter", "generation"])) return false; + return typeof value.relayUrl === "string" + && typeof value.token === "string" + && typeof value.expiresAt === "string" + && typeof value.refreshAfter === "string" + && typeof value.generation === "number" && Number.isSafeInteger(value.generation); +} + +export type DecodedControlFrame = + | { kind: "hello"; frame: CTLHello } + | { kind: "hello_ack"; frame: CTLHelloACK } + | { kind: "directory"; frame: CTLDirectory } + | { kind: "hint_update"; frame: CTLHintUpdate } + | { kind: "relay_passes"; frame: CTLRelayPasses } + | { kind: "snapshot_complete"; frame: CTLSnapshotComplete } + | { kind: "error"; frame: CTLError } + | { kind: "mint_request"; frame: CTLMintRequest } + | { kind: "publish_hint"; frame: CTLPublishHint } + | { kind: "ack"; frame: CTLACK }; + +/** Decode ANY control-plane envelope (server- or client-originated) into the + * generated wire types, enforcing the schemas' exact-keys and required-fields + * rules. Returns null on any deviation. Structure-preserving: the returned + * frame is the validated input value, so round-tripping through JSON is + * lossless (asserted by the golden-fixture tests). */ +export function decodeControlFrame(value: unknown): DecodedControlFrame | null { + if (!isObject(value) || typeof value.type !== "string") return null; + const payload = isObject(value.payload) ? value.payload : null; + if (payload === null) return null; + switch (value.type) { + case "hello": { + if (!validEnvelope(value, "hello", false)) return null; + if (!hasOnlyKeys( + payload, + ["endpointId", "wantPasses"], + ["haveRev", "deviceId", "platform", "appVersion", "releaseTrack", "capabilities"], + )) return null; + if (typeof payload.endpointId !== "string") return null; + if (typeof payload.wantPasses !== "boolean") return null; + if ("haveRev" in payload + && payload.haveRev !== null + && !(typeof payload.haveRev === "number" && Number.isSafeInteger(payload.haveRev))) { + return null; + } + if ("deviceId" in payload && typeof payload.deviceId !== "string") return null; + if ("platform" in payload + && !(typeof payload.platform === "string" && CLIENT_PLATFORMS.has(payload.platform))) { + return null; + } + if ("appVersion" in payload && typeof payload.appVersion !== "string") return null; + if ("releaseTrack" in payload + && !(typeof payload.releaseTrack === "string" && RELEASE_TRACKS.has(payload.releaseTrack))) { + return null; + } + if ("capabilities" in payload && !isStringArray(payload.capabilities)) return null; + return { kind: "hello", frame: value as unknown as CTLHello }; + } + case "hello_ack": { + if (!validEnvelope(value, "hello_ack", false)) return null; + if (!hasOnlyKeys( + payload, + ["sessionId"], + ["resumedFromRev", "serverCapabilities", "minimumSupportedVersion"], + )) return null; + if (typeof payload.sessionId !== "string") return null; + if ("resumedFromRev" in payload + && payload.resumedFromRev !== null + && !(typeof payload.resumedFromRev === "number" && Number.isSafeInteger(payload.resumedFromRev))) { + return null; + } + if ("serverCapabilities" in payload && !isStringArray(payload.serverCapabilities)) return null; + if ("minimumSupportedVersion" in payload + && !validMinimumSupportedVersion(payload.minimumSupportedVersion)) return null; + return { kind: "hello_ack", frame: value as unknown as CTLHelloACK }; + } + case "directory": { + if (!validEnvelope(value, "directory", true)) return null; + if (!hasOnlyKeys(payload, [ + "routeContractVersion", + "bindings", + "relayFleet", + "grantVerificationKeys", + "issuedAt", + "ttlSeconds", + ], ["minimumSupportedVersion"])) return null; + if (!(typeof payload.routeContractVersion === "number" + && Number.isSafeInteger(payload.routeContractVersion))) return null; + if (!Array.isArray(payload.bindings) || !payload.bindings.every(validBinding)) return null; + if (!Array.isArray(payload.relayFleet) + || !payload.relayFleet.every((url) => typeof url === "string")) return null; + if (!Array.isArray(payload.grantVerificationKeys) + || !payload.grantVerificationKeys.every(validGrantKey)) return null; + if (typeof payload.issuedAt !== "string") return null; + if (!(typeof payload.ttlSeconds === "number" && Number.isSafeInteger(payload.ttlSeconds))) { + return null; + } + if ("minimumSupportedVersion" in payload + && !validMinimumSupportedVersion(payload.minimumSupportedVersion)) return null; + return { kind: "directory", frame: value as unknown as CTLDirectory }; + } + case "hint_update": { + if (!validEnvelope(value, "hint_update", true)) return null; + if (!hasOnlyKeys(payload, ["endpointId", "homeRelayUrl"], ["updatedAt"])) return null; + if (typeof payload.endpointId !== "string") return null; + if (typeof payload.homeRelayUrl !== "string") return null; + if ("updatedAt" in payload && payload.updatedAt !== null && typeof payload.updatedAt !== "string") { + return null; + } + return { kind: "hint_update", frame: value as unknown as CTLHintUpdate }; + } + case "relay_passes": { + if (!validEnvelope(value, "relay_passes", true)) return null; + if (!hasOnlyKeys(payload, ["endpointId", "passes"])) return null; + if (typeof payload.endpointId !== "string") return null; + if (!Array.isArray(payload.passes) || !payload.passes.every(validPass)) return null; + return { kind: "relay_passes", frame: value as unknown as CTLRelayPasses }; + } + case "snapshot_complete": { + if (!validEnvelope(value, "snapshot_complete", true)) return null; + if (!hasOnlyKeys(payload, [], ["issuedAt"])) return null; + if ("issuedAt" in payload && typeof payload.issuedAt !== "string") return null; + return { kind: "snapshot_complete", frame: value as unknown as CTLSnapshotComplete }; + } + case "ack": { + if (!validEnvelope(value, "ack", true)) return null; + if (!hasOnlyKeys(payload, [], ["appliedAt"])) return null; + if ("appliedAt" in payload && typeof payload.appliedAt !== "string") return null; + return { kind: "ack", frame: value as unknown as CTLACK }; + } + case "error": { + if (!validEnvelope(value, "error", false)) return null; + if (!hasOnlyKeys(payload, ["code", "message", "retryable"])) return null; + if (typeof payload.code !== "string") return null; + if (typeof payload.message !== "string") return null; + if (typeof payload.retryable !== "boolean") return null; + return { kind: "error", frame: value as unknown as CTLError }; + } + case "mint_request": { + if (!validEnvelope(value, "mint_request", false)) return null; + if (!hasOnlyKeys(payload, ["endpointId"], ["proof"])) return null; + if (typeof payload.endpointId !== "string") return null; + if ("proof" in payload && !validProof(payload.proof)) return null; + return { kind: "mint_request", frame: value as unknown as CTLMintRequest }; + } + case "publish_hint": { + if (!validEnvelope(value, "publish_hint", false)) return null; + if (!hasOnlyKeys(payload, ["endpointId", "homeRelayUrl"], ["proof"])) return null; + if (typeof payload.endpointId !== "string") return null; + if (typeof payload.homeRelayUrl !== "string") return null; + if ("proof" in payload && !validProof(payload.proof)) return null; + return { kind: "publish_hint", frame: value as unknown as CTLPublishHint }; + } + default: + return null; + } +} + +// ---- Broker-truth payload shapes (what DIR_KEY stores) ---- + +/** A binding as the upstream discovery response asserts it. Overlay fields + * (status/revoked/version/track/capabilities/confirmation) are DO-owned and + * joined in at directory build time, never stored in broker truth. */ +export type BrokerBinding = Omit< + Binding, + "status" | "revoked" | "appVersion" | "releaseTrack" | "capabilities" | "lastConfirmedAt" +>; + +/** What DIR_KEY stores: broker truth only — no overlay join and no freshness + * stamps (issuedAt/ttlSeconds are stamped per outbound frame, so a re-stamp + * never looks like a content change). */ +export interface BrokerDirectoryPayload { + routeContractVersion: number; + bindings: BrokerBinding[]; + relayFleet: string[]; + grantVerificationKeys: GrantVerificationKey[]; + minimumSupportedVersion?: PurpleMinimumSupportedVersion; +} + +/** The wire directory body minus the per-send freshness stamps. */ +export type WireDirectoryBody = Omit; + +// ---- Server frame builders ---- + +export function helloAckFrame( + sessionId: string, + resumedFromRev: number | null, + minimumSupportedVersion: PurpleMinimumSupportedVersion | null = null, +): CTLHelloACK { + return { + v: CONTROL_PROTOCOL_VERSION, + type: "hello_ack", + payload: { + sessionId, + resumedFromRev, + serverCapabilities: [...CONTROL_SERVER_CAPABILITIES], + ...(minimumSupportedVersion ? { minimumSupportedVersion } : {}), + }, + }; +} + +export function directoryFrame( + rev: number, + body: WireDirectoryBody, + issuedAtIso: string, +): CTLDirectory { + return { + v: CONTROL_PROTOCOL_VERSION, + type: "directory", + rev, + payload: { ...body, issuedAt: wireDate(issuedAtIso), ttlSeconds: DIRECTORY_TTL_SECONDS }, + }; +} + +export function relayPassesFrame(rev: number, endpointId: string, passes: Pass[]): CTLRelayPasses { + return { + v: CONTROL_PROTOCOL_VERSION, + type: "relay_passes", + rev, + payload: { endpointId, passes }, + }; +} + +export function hintUpdateFrame( + rev: number, + endpointId: string, + homeRelayUrl: string, + updatedAtIso: string | null, +): CTLHintUpdate { + return { + v: CONTROL_PROTOCOL_VERSION, + type: "hint_update", + rev, + payload: { + endpointId, + homeRelayUrl, + updatedAt: updatedAtIso === null ? null : wireDate(updatedAtIso), + }, + }; +} + +/** snapshot_complete doubles as the explicit-freshness "current" frame: when a + * hello's haveRev already equals head, this stamp alone re-arms the client's + * directory trust lease (no separate frame type on the wire). */ +export function snapshotCompleteFrame(rev: number, issuedAtIso: string): CTLSnapshotComplete { + return { + v: CONTROL_PROTOCOL_VERSION, + type: "snapshot_complete", + rev, + payload: { issuedAt: wireDate(issuedAtIso) }, + }; +} + +export function errorFrame(code: string, message: string, retryable: boolean): CTLError { + return { v: CONTROL_PROTOCOL_VERSION, type: "error", payload: { code, message, retryable } }; +} + +// ---- Upstream response mapping ---- + +/** Map the broker discovery response (GET api/devices/iroh; see + * web/services/iroh/trustBroker.ts serializeDiscovery) onto the wire + * directory payload. `revision` is the broker's monotonic account route + * revision, or null when the response omits it (the DO then falls back to a + * storage counter). Returns null when the response is not a discovery shape, + * which callers treat as an upstream failure. Individual malformed bindings + * are skipped rather than failing the whole directory. */ +export function directoryPayloadFromDiscovery( + value: unknown, +): { revision: number | null; payload: BrokerDirectoryPayload } | null { + if (!isObject(value)) return null; + if (!Array.isArray(value.bindings)) return null; + + const bindings: BrokerBinding[] = []; + for (const raw of value.bindings) { + if (!isObject(raw)) continue; + const bindingId = raw.binding_id; + const endpointId = raw.endpoint_id; + const clientNamespace = raw.client_namespace; + if (typeof bindingId !== "string" || typeof endpointId !== "string" + || typeof clientNamespace !== "string") continue; + let homeRelayUrl: string | null = null; + if (Array.isArray(raw.path_hints)) { + for (const hint of raw.path_hints) { + if (isObject(hint) && hint.kind === "relay_url" && typeof hint.value === "string") { + homeRelayUrl = hint.value; + break; + } + } + } + bindings.push({ + bindingId, + endpointId, + clientNamespace, + deviceId: typeof raw.device_id === "string" ? raw.device_id : null, + instanceTag: typeof raw.tag === "string" ? raw.tag : null, + homeRelayUrl, + updatedAt: typeof raw.last_seen_at === "string" ? wireDate(raw.last_seen_at) : null, + }); + } + + const relayFleet = Array.isArray(value.relay_fleet) + ? value.relay_fleet.filter((url): url is string => typeof url === "string") + : []; + + const grantVerificationKeys: GrantVerificationKey[] = []; + const keySet = value.grant_verification_keys; + if (isObject(keySet) && Array.isArray(keySet.keys)) { + for (const raw of keySet.keys) { + if (!isObject(raw)) continue; + if (typeof raw.kid !== "string" || typeof raw.alg !== "string" + || typeof raw.spki_der_base64 !== "string") continue; + grantVerificationKeys.push({ + keyId: raw.kid, + alg: raw.alg, + publicKey: raw.spki_der_base64, + }); + } + } + + const routeContractVersion = + typeof value.route_contract_version === "number" + && Number.isSafeInteger(value.route_contract_version) + ? value.route_contract_version + : 1; + const revision = + typeof value.revision === "number" + && Number.isSafeInteger(value.revision) + && value.revision > 0 + ? value.revision + : null; + + // Optional per-platform app-version floors, when the broker publishes them. + let minimumSupportedVersion: PurpleMinimumSupportedVersion | undefined; + const minRaw = value.minimum_supported_version; + if (isObject(minRaw)) { + const mac = typeof minRaw.mac === "string" ? minRaw.mac : undefined; + const ios = typeof minRaw.ios === "string" ? minRaw.ios : undefined; + if (mac !== undefined || ios !== undefined) { + minimumSupportedVersion = { + ...(mac !== undefined ? { mac } : {}), + ...(ios !== undefined ? { ios } : {}), + }; + } + } + + return { + revision, + payload: { + routeContractVersion, + bindings, + relayFleet, + grantVerificationKeys, + ...(minimumSupportedVersion !== undefined ? { minimumSupportedVersion } : {}), + }, + }; +} + +/** Map the relay-token mint response (POST api/relay/token; see + * web/app/api/relay/token/route.ts) onto wire passes. Prefers the + * per-relay `relayCredentials` set and falls back to the legacy homogeneous + * `token`/`expiresAt`/`refreshAfter`/`relays` fields. Returns null when the + * response issued no credentials (policy-only response for an unregistered + * endpoint or an unsigned local runtime). `generation` is DO-assigned: the + * broker response has no generation, so the DO stamps each mint batch with a + * per-endpoint counter. */ +export function passesFromMintResponse(value: unknown, generation: number): Pass[] | null { + if (!isObject(value)) return null; + + const fromEpochSeconds = (raw: unknown): string | null => + typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0 + ? rfc3339FromMs(raw * 1000) + : null; + + if (Array.isArray(value.relayCredentials)) { + const passes: Pass[] = []; + for (const raw of value.relayCredentials) { + if (!isObject(raw)) return null; + const expiresAt = fromEpochSeconds(raw.expiresAt); + const refreshAfter = fromEpochSeconds(raw.refreshAfter); + if (typeof raw.relayUrl !== "string" || typeof raw.token !== "string" + || expiresAt === null || refreshAfter === null) return null; + passes.push({ + relayUrl: raw.relayUrl, + token: raw.token, + expiresAt: wireDate(expiresAt), + refreshAfter: wireDate(refreshAfter), + generation, + }); + } + return passes.length > 0 ? passes : null; + } + + if (typeof value.token === "string" && Array.isArray(value.relays)) { + const expiresAt = fromEpochSeconds(value.expiresAt); + if (expiresAt === null) return null; + // Legacy responses carry no refreshAfter; refresh at expiry. + const refreshAfter = fromEpochSeconds(value.refreshAfter) ?? expiresAt; + const passes: Pass[] = []; + for (const relayUrl of value.relays) { + if (typeof relayUrl !== "string") continue; + passes.push({ + relayUrl, + token: value.token, + expiresAt: wireDate(expiresAt), + refreshAfter: wireDate(refreshAfter), + generation, + }); + } + return passes.length > 0 ? passes : null; + } + + return null; +} + +// ---- Directory delta classification ---- + +export type DirectoryDelta = + | { kind: "none" } + | { kind: "hints"; updates: { endpointId: string; homeRelayUrl: string; updatedAt: string | null }[] } + | { kind: "full" }; + +/** Classify what changed between two directory payloads so refresh broadcasts + * stay minimal: pure home-relay-hint movement becomes per-binding hint_update + * frames; anything structural (bindings added/removed, identity or trust + * material changed, a hint removed — hint_update cannot express null) falls + * back to a full directory frame. */ +export function directoryDelta( + previous: BrokerDirectoryPayload | undefined, + next: BrokerDirectoryPayload, +): DirectoryDelta { + if (previous === undefined) return { kind: "full" }; + if (JSON.stringify(previous) === JSON.stringify(next)) return { kind: "none" }; + if (previous.routeContractVersion !== next.routeContractVersion) return { kind: "full" }; + if (JSON.stringify(previous.relayFleet) !== JSON.stringify(next.relayFleet)) return { kind: "full" }; + if (JSON.stringify(previous.grantVerificationKeys) !== JSON.stringify(next.grantVerificationKeys)) { + return { kind: "full" }; + } + if (JSON.stringify(previous.minimumSupportedVersion ?? null) + !== JSON.stringify(next.minimumSupportedVersion ?? null)) { + return { kind: "full" }; + } + const prevById = new Map(previous.bindings.map((binding) => [binding.bindingId, binding])); + if (prevById.size !== next.bindings.length) return { kind: "full" }; + const updates: { endpointId: string; homeRelayUrl: string; updatedAt: string | null }[] = []; + for (const binding of next.bindings) { + const prev = prevById.get(binding.bindingId); + if (!prev) return { kind: "full" }; + const { homeRelayUrl: prevHint, updatedAt: prevAt, ...prevRest } = prev; + const { homeRelayUrl: nextHint, updatedAt: nextAt, ...nextRest } = binding; + if (JSON.stringify(prevRest) !== JSON.stringify(nextRest)) return { kind: "full" }; + if ((prevHint ?? null) === (nextHint ?? null)) continue; + if (typeof nextHint !== "string") return { kind: "full" }; // hint removed + updates.push({ + endpointId: binding.endpointId, + homeRelayUrl: nextHint, + updatedAt: typeof nextAt === "string" ? nextAt : null, + }); + } + return updates.length > 0 ? { kind: "hints", updates } : { kind: "none" }; +} + +// ---- Core dependencies (injected; the DO adapter and tests provide them) ---- + +export interface CtlAttachment { + sessionId: string; + /** Optional lifecycle cutoff used by test doubles and legacy adapters. The + * production control adapter uses a non-expiring sentinel after authenticating + * the upgrade in the worker. */ + expiresAt?: number; + /** Validated x-cmux-app-namespace from the upgrade request; forwarded on + * upstream calls so discovery/mint see the same namespace the client's own + * HTTPS calls would carry. */ + namespace?: string; + /** Declared by hello. Phase A trusts the declaration: facts are account- + * scoped, and passes minted for a declared endpointId are useless to any + * other key by relay design. */ + endpointId?: string; + wantPasses?: boolean; + /** True once a hello arrived. Only helloed sockets receive broadcasts. */ + helloed?: boolean; + /** True when the initial directory fetch failed with no cache to serve; the + * alarm retries and completes the snapshot when upstream recovers. */ + snapshotPending?: boolean; + /** Highest revision this socket has acked (client applied that directory or + * hint revision). Mirrored into the device overlay's lastAckedRev when the + * socket is bound to a known device. */ + lastAckedRev?: number; + /** Retry-ladder state for the newest revision delivered but not yet acked: + * resend the LATEST directory at nextAt, then climb the ladder. Cleared by + * an ack covering `rev`. */ + ackRetry?: { rev: number; attempt: number; nextAt: number }; +} + +// ---- Device revocation (worker HTTP route -> account DO) ---- + +export interface RevocationRequest { + endpointId: string; + revoked: boolean; +} + +/** Strict body parse for POST /v1/control/devices/revoke, shared by the + * worker route and the DO adapter. The account identity NEVER rides in this + * body — the worker derives the DO from the verified Stack user id. */ +export function parseRevocationRequest(value: unknown): RevocationRequest | null { + if (!isObject(value)) return null; + if (!hasOnlyKeys(value, ["endpointId", "revoked"])) return null; + if (typeof value.endpointId !== "string" + || value.endpointId.length === 0 + || value.endpointId.length > MAX_ENDPOINT_ID_CHARS) return null; + if (typeof value.revoked !== "boolean") return null; + return { endpointId: value.endpointId, revoked: value.revoked }; +} + +export interface CtlSocket { + send(data: string): void; + close(code?: number, reason?: string): void; + getAttachment(): CtlAttachment | null; + setAttachment(attachment: CtlAttachment): void; +} + +export interface CtlStorage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; + delete(key: string): Promise; + /** Prefix scan (DurableObjectStorage.list-compatible). */ + list(options: { prefix: string }): Promise>; +} + +export interface CtlUpstreamInit { + method: string; + headers: Record; + body?: string; +} + +export interface CtlUpstreamResult { + status: number; + json: unknown; +} + +export interface CtlDeps { + storage: CtlStorage; + now(): number; + /** Perform one upstream HTTPS call against the configured Vercel base URL. + * MUST throw on connection-level failure (DNS, TCP, TLS, aborted body) and + * resolve with the status for any HTTP response. */ + upstream(path: string, init: CtlUpstreamInit): Promise; + /** Pull the DO alarm earlier if `atMs` precedes the currently scheduled + * one (ensure-at semantics, provided by the adapter). */ + scheduleAlarmAt(atMs: number): Promise; + /** All currently connected sockets (hibernation-aware in the adapter). */ + sockets(): CtlSocket[]; +} + +export interface CtlConnectInput { + sessionId: string; + expiresAt?: number; + bearer: string; + /** Stack refresh token: the web API's native auth (parseNativeStackTokens) + * requires BOTH the bearer and x-stack-refresh-token; a bearer alone 401s. */ + refresh?: string; + namespace?: string; +} + +/** Stored per-socket credentials under BEARER_PREFIX. Serialized as JSON; + * a bare-string value (pre-refresh deploys) is read as bearer-only. */ +interface StoredCtlCredentials { + bearer: string; + refresh?: string; +} + +function encodeStoredCredentials(input: StoredCtlCredentials): string { + return JSON.stringify(input); +} + +function decodeStoredCredentials(raw: string | undefined): StoredCtlCredentials | undefined { + if (raw === undefined) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null + && typeof (parsed as { bearer?: unknown }).bearer === "string") { + const refresh = (parsed as { refresh?: unknown }).refresh; + return { + bearer: (parsed as { bearer: string }).bearer, + ...(typeof refresh === "string" ? { refresh } : {}), + }; + } + } catch { + // fall through: legacy bare-bearer value + } + return { bearer: raw }; +} + +const DISCOVERY_PATH = "/api/devices/iroh"; +const MINT_PATH = "/api/relay/token"; + +function upstreamHeaders(credentials: StoredCtlCredentials, namespace: string | undefined, json: boolean): Record { + return { + authorization: `Bearer ${credentials.bearer}`, + ...(credentials.refresh ? { "x-stack-refresh-token": credentials.refresh } : {}), + accept: "application/json", + ...(json ? { "content-type": "application/json" } : {}), + ...(namespace ? { "x-cmux-app-namespace": namespace } : {}), + }; +} + +export class ControlPlaneCore { + constructor(private readonly deps: CtlDeps) {} + + // ---- Connection lifecycle ---- + + async handleConnect(socket: CtlSocket, input: CtlConnectInput): Promise { + socket.setAttachment({ + sessionId: input.sessionId, + ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}), + ...(input.namespace ? { namespace: input.namespace } : {}), + }); + await this.deps.storage.put( + BEARER_PREFIX + input.sessionId, + encodeStoredCredentials({ + bearer: input.bearer, + ...(input.refresh ? { refresh: input.refresh } : {}), + }), + ); + const now = this.deps.now(); + await this.deps.scheduleAlarmAt( + input.expiresAt === undefined + ? now + CONTROL_REFRESH_INTERVAL_MS + : Math.min(now + CONTROL_REFRESH_INTERVAL_MS, input.expiresAt), + ); + } + + async handleClose(socket: CtlSocket): Promise { + const attachment = socket.getAttachment(); + if (attachment) await this.deps.storage.delete(BEARER_PREFIX + attachment.sessionId); + } + + // ---- Inbound messages ---- + + async handleMessage(socket: CtlSocket, message: string | ArrayBuffer): Promise { + const attachment = socket.getAttachment(); + if (!attachment) return; + const now = this.deps.now(); + if (attachment.expiresAt !== undefined && attachment.expiresAt <= now) { + try { + socket.close(1000, "subscription expired; reconnect with a fresh token"); + } catch { + // already closed + } + return; + } + // Bound BEFORE parse: client-controlled input on a live DO. + const byteLength = typeof message === "string" ? message.length : message.byteLength; + if (byteLength > MAX_CONTROL_MESSAGE_BYTES) return; + let body: unknown; + try { + body = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message)); + } catch { + return; // not JSON; ignore (consistent with the presence DO) + } + // Heartbeats are transport liveness frames, not durable control facts. + if (isObject(body) && body.type === CONTROL_HEARTBEAT_TYPE) { + try { + socket.send(JSON.stringify({ + v: CONTROL_PROTOCOL_VERSION, + type: "pong", + payload: { at: new Date(now).toISOString() }, + })); + } catch { + // The peer went away between receive and reply. + } + return; + } + if (isObject(body) && body.type === "pong") return; + const decoded = decodeControlFrame(body); + if (decoded === null) return; + switch (decoded.kind) { + case "hello": + await this.handleHello(socket, attachment, decoded.frame); + return; + case "mint_request": + await this.handleMintRequest(socket, attachment, decoded.frame); + return; + case "publish_hint": + await this.handlePublishHint(socket, attachment, decoded.frame); + return; + case "ack": + await this.handleAck(socket, attachment, decoded.frame); + return; + default: + return; // server-to-client frame echoed back; ignore + } + } + + // ---- hello: stream facts as each becomes ready, never assemble-then-send ---- + + private async handleHello( + socket: CtlSocket, + attachment: CtlAttachment, + frame: CTLHello, + ): Promise { + // One hello per connection: a repeat would let an authenticated client + // force repeated upstream fetches; the supported resync path is reconnect + // (same rule as the presence DO's sync.hello). + if (attachment.helloed) return; + const payload = frame.payload; + if (payload.endpointId.length > MAX_ENDPOINT_ID_CHARS) return; + attachment.helloed = true; + attachment.endpointId = payload.endpointId; + attachment.wantPasses = payload.wantPasses; + socket.setAttachment(attachment); + + // Confirm-on-hello BEFORE reading the head revision: the overlay flip + // (seeded -> active, plus version/track/capabilities) bumps the revision + // and broadcasts to peers, and the snapshot this client is about to + // receive must already show its own confirmed entry. + await this.confirmDeviceFromHello(attachment, payload); + + const storedRev = await this.deps.storage.get(REV_KEY); + const cached = await this.deps.storage.get(DIR_KEY); + const haveRev = payload.haveRev ?? null; + const resumed = haveRev !== null && storedRev !== undefined + && haveRev === storedRev && cached !== undefined; + + this.sendFrame(socket, attachment, helloAckFrame( + attachment.sessionId, + resumed ? haveRev : null, + // Version floors ride the hello_ack too, so clients hold them even + // before (or without) a directory body. + cached?.minimumSupportedVersion ?? null, + )); + + if (resumed) { + // Client already holds the current snapshot; skip the directory body. + // snapshot_complete carries issuedAt: that stamp alone re-arms the + // client's trust lease (the explicit-freshness "current" role rides on + // the existing frame instead of adding a new one). The 60s alarm + // refresh heals any staleness the DO itself has. + await this.finishSnapshot(socket, attachment, storedRev); + return; + } + + const fetched = await this.fetchDirectory(attachment); + if (fetched === null) { + this.sendFrame(socket, attachment, errorFrame( + "directory_unavailable", + "directory fetch failed upstream; retrying", + true, + )); + if (cached !== undefined && storedRev !== undefined) { + // Serve the cached facts (stale rev is honest: the client can resume + // from it) and complete the snapshot; the alarm refresh delivers + // deltas when upstream recovers. + await this.sendDirectory(socket, attachment, storedRev, cached); + await this.finishSnapshot(socket, attachment, storedRev); + } else { + attachment.snapshotPending = true; + socket.setAttachment(attachment); + await this.deps.scheduleAlarmAt(this.deps.now() + SNAPSHOT_RETRY_DELAY_MS); + } + return; + } + + const { rev, previous, previousRev } = await this.storeDirectory(fetched); + await this.sendDirectory(socket, attachment, rev, fetched.payload); + // This fetch doubles as a refresh for everyone else already snapshotted. + await this.broadcastDirectoryChange(previous, fetched.payload, rev, previousRev, attachment.sessionId); + await this.finishSnapshot(socket, attachment, rev); + } + + // ---- Confirm-on-hello: client info claims the device's overlay entry ---- + + /** A hello carrying any client-info field is the device confirming itself: + * its overlay flips to "active", lastConfirmedAt is stamped, and declared + * version/track/capabilities are recorded. That is a revision-bearing list + * change, so the account revision bumps and peers get the new directory + * (the confirming socket is excluded — its snapshot arrives inline). + * Oversized client info skips the confirmation, never the hello. */ + private async confirmDeviceFromHello( + attachment: CtlAttachment, + payload: CTLHelloPayload, + ): Promise { + const hasClientInfo = payload.deviceId != null || payload.platform != null + || payload.appVersion != null || payload.releaseTrack != null + || payload.capabilities != null; + if (!hasClientInfo) return; + if (payload.deviceId != null && payload.deviceId.length > MAX_DEVICE_ID_CHARS) return; + if (payload.appVersion != null && payload.appVersion.length > MAX_APP_VERSION_CHARS) return; + if (payload.capabilities != null && ( + payload.capabilities.length > MAX_CLIENT_CAPABILITIES + || payload.capabilities.some((cap) => cap.length > MAX_CLIENT_CAPABILITY_CHARS) + )) return; + const overlay = await this.ensureOverlay(payload.endpointId); + const updated: DeviceOverlay = { + ...overlay, + status: "active", + lastConfirmedAt: rfc3339FromMs(this.deps.now()), + ...(payload.appVersion != null ? { appVersion: payload.appVersion } : {}), + ...(payload.releaseTrack != null ? { releaseTrack: payload.releaseTrack } : {}), + ...(payload.capabilities != null ? { capabilities: [...payload.capabilities] } : {}), + ...(payload.deviceId != null ? { deviceId: payload.deviceId } : {}), + ...(attachment.namespace !== undefined ? { clientNamespace: attachment.namespace } : {}), + }; + await this.deps.storage.put(DEV_PREFIX + payload.endpointId, updated); + await this.bumpOverlayRevisionAndBroadcast(attachment.sessionId); + } + + /** Mint passes if requested, then close the snapshot. */ + private async finishSnapshot( + socket: CtlSocket, + attachment: CtlAttachment, + rev: number, + ): Promise { + if (attachment.wantPasses && attachment.endpointId) { + await this.mintAndSend(socket, attachment, attachment.endpointId, rev); + } + this.sendFrame(socket, attachment, snapshotCompleteFrame(rev, rfc3339FromMs(this.deps.now()))); + if (attachment.snapshotPending) { + attachment.snapshotPending = false; + socket.setAttachment(attachment); + } + } + + // ---- Directory delivery + ack tracking ---- + + /** Send one socket the merged, freshness-stamped directory and arm its ack + * retry ladder for that revision. */ + private async sendDirectory( + socket: CtlSocket, + attachment: CtlAttachment, + rev: number, + broker: BrokerDirectoryPayload, + ): Promise { + const merged = await this.mergedDirectory(broker); + this.sendFrame(socket, attachment, directoryFrame(rev, merged, rfc3339FromMs(this.deps.now()))); + await this.markAckPending(socket, attachment, rev); + } + + /** Arm (or re-arm at the newest revision) the socket's ack retry ladder and + * pull the alarm to the first rung. No-op when the socket already acked this + * revision or newer. */ + private async markAckPending( + socket: CtlSocket, + attachment: CtlAttachment, + rev: number, + ): Promise { + if ((attachment.lastAckedRev ?? -1) >= rev) return; + const now = this.deps.now(); + const nextAt = now + (ACK_RETRY_LADDER_MS[0] ?? ACK_RETRY_STEADY_MS); + attachment.ackRetry = { rev, attempt: 0, nextAt }; + socket.setAttachment(attachment); + await this.deps.scheduleAlarmAt(nextAt); + } + + // ---- ack: the client applied revision `rev`; stand the ladder down ---- + + private async handleAck( + socket: CtlSocket, + attachment: CtlAttachment, + frame: CTLACK, + ): Promise { + const rev = frame.rev; + // Stale acks are fine; ignore anything at or below the current watermark. + if ((attachment.lastAckedRev ?? -1) >= rev) return; + attachment.lastAckedRev = rev; + if (attachment.ackRetry !== undefined && rev >= attachment.ackRetry.rev) { + delete attachment.ackRetry; + } + socket.setAttachment(attachment); + // Mirror into the device overlay when the socket is bound to a known + // device (bookkeeping only: no revision bump, never emitted). + if (attachment.endpointId) { + const overlay = await this.deps.storage.get(DEV_PREFIX + attachment.endpointId); + if (overlay !== undefined && (overlay.lastAckedRev ?? -1) < rev) { + await this.deps.storage.put(DEV_PREFIX + attachment.endpointId, { + ...overlay, + lastAckedRev: rev, + }); + } + } + } + + // ---- mint_request: proxy the exact client mint with the socket's own token ---- + + private async handleMintRequest( + socket: CtlSocket, + attachment: CtlAttachment, + frame: CTLMintRequest, + ): Promise { + const endpointId = frame.payload.endpointId; + if (!endpointId || endpointId.length > MAX_ENDPOINT_ID_CHARS) return; + const rev = (await this.deps.storage.get(REV_KEY)) ?? 0; + await this.mintAndSend(socket, attachment, endpointId, rev); + } + + /** Replicate the Swift client's own mint (POST api/relay/token with bearer + * auth and body {"endpointId"}; see CmxIrohTrustBrokerClient + * issueRelayBootstrap) using THIS socket's stored token, with one immediate + * retry on connection-level failure. Phase A ignores any proof on the + * message: the broker authorizes the endpoint from its registration state. + * The reply goes to this socket only. */ + private async mintAndSend( + socket: CtlSocket, + attachment: CtlAttachment, + endpointId: string, + rev: number, + ): Promise { + // A revoked device keeps its socket and may see the directory, but never + // fresh relay credentials. Non-retryable: only an un-revoke (or asking for + // a non-revoked endpoint) changes the answer. Checked for both the minted + // endpoint and the requesting socket's own bound endpoint. + if (await this.isEndpointRevoked(endpointId) + || (attachment.endpointId !== undefined + && attachment.endpointId !== endpointId + && await this.isEndpointRevoked(attachment.endpointId))) { + this.sendFrame(socket, attachment, errorFrame( + "mint_revoked", + "device revoked for this account", + false, + )); + return; + } + const credentials = decodeStoredCredentials( + await this.deps.storage.get(BEARER_PREFIX + attachment.sessionId), + ); + if (credentials === undefined) { + this.sendFrame(socket, attachment, errorFrame( + "mint_unauthorized", + "no bearer token for this connection; reconnect", + false, + )); + return; + } + const result = await this.upstreamOnceRetry(MINT_PATH, { + method: "POST", + headers: upstreamHeaders(credentials, attachment.namespace, true), + body: JSON.stringify({ endpointId }), + }); + if (result === null) { + this.sendFrame(socket, attachment, errorFrame( + "mint_upstream_unavailable", + "relay token mint failed upstream", + true, + )); + return; + } + if (result.status < 200 || result.status >= 300) { + const retryable = result.status >= 500 || result.status === 429; + this.sendFrame(socket, attachment, errorFrame( + retryable ? "mint_upstream_unavailable" : "mint_rejected", + `relay token mint failed upstream (${result.status})`, + retryable, + )); + return; + } + const generation = ((await this.deps.storage.get(GEN_PREFIX + endpointId)) ?? 0) + 1; + const passes = passesFromMintResponse(result.json, generation); + if (passes === null) { + this.sendFrame(socket, attachment, errorFrame( + "mint_no_credentials", + "upstream issued no relay credentials for this endpoint", + false, + )); + return; + } + await this.deps.storage.put(GEN_PREFIX + endpointId, generation); + this.sendFrame(socket, attachment, relayPassesFrame(rev, endpointId, passes)); + } + + // ---- publish_hint: instant-propagation announcement + confirm re-fetch ---- + + /** Phase A never writes hints to Vercel (upstream hint registration is a + * challenge + endpoint-signed flow only the Mac itself can perform; the Mac + * keeps doing that over HTTPS in parallel). The socket path is the + * instant-propagation lane: broadcast the claim to the account's OTHER + * sockets at the current known rev, then confirm against broker truth a few + * seconds later and re-broadcast if the authoritative revision moved. Spoof + * scope is bounded to same-account devices, and a wrong announcement costs + * peers one failed dial before the confirm pass corrects it. */ + private async handlePublishHint( + socket: CtlSocket, + attachment: CtlAttachment, + frame: CTLPublishHint, + ): Promise { + const { endpointId, homeRelayUrl } = frame.payload; + if (!endpointId || endpointId.length > MAX_ENDPOINT_ID_CHARS) return; + if (!isPlausibleRelayUrl(homeRelayUrl)) { + this.sendFrame(socket, attachment, errorFrame( + "invalid_hint", + "homeRelayUrl must be an http(s) URL", + false, + )); + return; + } + const now = this.deps.now(); + const rev = (await this.deps.storage.get(REV_KEY)) ?? 0; + const frameJson = JSON.stringify( + hintUpdateFrame(rev, endpointId, homeRelayUrl, rfc3339FromMs(now)), + ); + // The announcement is NOT revision-bearing (rev did not move), so it never + // arms the peers' ack retry ladders; the confirm re-fetch does when the + // broker revision actually advances. + for (const peer of this.deps.sockets()) { + const peerAttachment = peer.getAttachment(); + if (!peerAttachment) continue; + if (peerAttachment.sessionId === attachment.sessionId) continue; // announcer knows its own hint + if (!this.deliverable(peerAttachment, now)) continue; + try { + peer.send(frameJson); + } catch { + // Socket already gone; hibernation cleans it up. + } + } + await this.deps.scheduleAlarmAt(now + HINT_CONFIRM_DELAY_MS); + } + + // ---- Device overlay (listv2): storage-driven, joined at directory build ---- + + /** Load a device's overlay, materializing the seeded default on first + * sighting so admin mutations (revoke) always have a record to land on. */ + private async ensureOverlay(endpointId: string): Promise { + const existing = await this.deps.storage.get(DEV_PREFIX + endpointId); + if (existing !== undefined) return existing; + const seeded: DeviceOverlay = { status: "seeded", revoked: false }; + await this.deps.storage.put(DEV_PREFIX + endpointId, seeded); + return seeded; + } + + private async isEndpointRevoked(endpointId: string): Promise { + const overlay = await this.deps.storage.get(DEV_PREFIX + endpointId); + return overlay?.revoked === true; + } + + /** Join broker bindings with the DO-owned overlay. Bindings never seen + * before get a seeded overlay row created; overlay rows whose binding + * disappeared upstream are kept in storage but not emitted. lastAckedRev is + * bookkeeping and never emitted. */ + private async mergedDirectory(broker: BrokerDirectoryPayload): Promise { + const bindings: Binding[] = []; + const emitted = new Set(); + for (const binding of broker.bindings) { + const overlay = await this.ensureOverlay(binding.endpointId); + emitted.add(binding.endpointId); + bindings.push({ + ...binding, + status: overlay.status, + revoked: overlay.revoked, + ...(overlay.appVersion !== undefined ? { appVersion: overlay.appVersion } : {}), + ...(overlay.releaseTrack !== undefined ? { releaseTrack: overlay.releaseTrack } : {}), + ...(overlay.capabilities !== undefined ? { capabilities: overlay.capabilities } : {}), + ...(overlay.lastConfirmedAt !== undefined + ? { lastConfirmedAt: wireDate(overlay.lastConfirmedAt) } + : {}), + }); + } + // Confirmed-but-unlisted devices: a device that proved itself over its + // own authenticated hello stays in the emitted directory even when the + // upstream discovery view omits it (namespace-filtered upstream views, + // registration lag, caller self-exclusion). Without this, the device's + // peers fail closed against it — the exact wedge the confirm-on-hello + // contract exists to prevent. Bounded by the directory TTL so an + // upstream deletion cannot outlive the trust lease; revoked rides along + // so peers still see the kill switch. + const overlays = await this.deps.storage.list({ prefix: DEV_PREFIX }); + const cutoffMs = this.deps.now() - DIRECTORY_TTL_SECONDS * 1000; + for (const [key, overlay] of overlays) { + const endpointId = key.slice(DEV_PREFIX.length); + if (emitted.has(endpointId)) continue; + if (overlay.status !== "active") continue; + const confirmedAtMs = overlay.lastConfirmedAt === undefined + ? Number.NaN + : Date.parse(overlay.lastConfirmedAt); + if (!(confirmedAtMs > cutoffMs)) continue; + bindings.push({ + bindingId: `ctl-hello:${endpointId}`, + clientNamespace: overlay.clientNamespace ?? "legacy", + endpointId, + status: overlay.status, + revoked: overlay.revoked, + ...(overlay.deviceId !== undefined ? { deviceId: overlay.deviceId } : {}), + ...(overlay.appVersion !== undefined ? { appVersion: overlay.appVersion } : {}), + ...(overlay.releaseTrack !== undefined ? { releaseTrack: overlay.releaseTrack } : {}), + ...(overlay.capabilities !== undefined ? { capabilities: overlay.capabilities } : {}), + ...(overlay.lastConfirmedAt !== undefined + ? { lastConfirmedAt: wireDate(overlay.lastConfirmedAt) } + : {}), + }); + } + return { ...broker, bindings }; + } + + /** Bump the account revision for a DO-local overlay change (confirm-on-hello + * or revocation — broker truth did not move, so storeDirectory could never + * see it) and broadcast the merged directory to every snapshotted socket, + * except the optionally excluded one whose snapshot arrives inline. */ + private async bumpOverlayRevisionAndBroadcast(excludeSessionId: string | null): Promise { + const previousRev = (await this.deps.storage.get(REV_KEY)) ?? 0; + const rev = previousRev + 1; + await this.deps.storage.put(REV_KEY, rev); + const broker = await this.deps.storage.get(DIR_KEY); + if (broker === undefined) return rev; // no directory yet; nothing to broadcast + const merged = await this.mergedDirectory(broker); + const frameJson = JSON.stringify(directoryFrame(rev, merged, rfc3339FromMs(this.deps.now()))); + const now = this.deps.now(); + for (const socket of this.deps.sockets()) { + const peer = socket.getAttachment(); + if (!peer) continue; + if (excludeSessionId !== null && peer.sessionId === excludeSessionId) continue; + if (!this.deliverable(peer, now)) continue; + try { + socket.send(frameJson); + } catch { + continue; // Socket already gone; hibernation cleans it up. + } + await this.markAckPending(socket, peer, rev); + } + return rev; + } + + // ---- Revocation: account-owner kill switch over the worker HTTP route ---- + + /** Flip one device's revoked flag (status untouched — revoked is + * orthogonal). Idempotent. On revoke: bump the revision, broadcast the + * merged directory immediately (the revoked device may still see the list), + * then close every socket bound to that endpoint with 1008 "revoked". Mints + * for the endpoint are refused until un-revoked. */ + async handleRevocation( + request: RevocationRequest, + ): Promise<{ rev: number; changed: boolean; revoked: boolean }> { + const overlay = await this.ensureOverlay(request.endpointId); + if (overlay.revoked === request.revoked) { + const rev = (await this.deps.storage.get(REV_KEY)) ?? 0; + return { rev, changed: false, revoked: overlay.revoked }; + } + await this.deps.storage.put(DEV_PREFIX + request.endpointId, { + ...overlay, + revoked: request.revoked, + }); + const rev = await this.bumpOverlayRevisionAndBroadcast(null); + if (request.revoked) { + for (const socket of this.deps.sockets()) { + const attachment = socket.getAttachment(); + if (!attachment || attachment.endpointId !== request.endpointId) continue; + await this.deps.storage.delete(BEARER_PREFIX + attachment.sessionId); + try { + socket.close(1008, "revoked"); + } catch { + // already closed + } + } + } + return { rev, changed: true, revoked: request.revoked }; + } + + // ---- Alarm: periodic refresh + pending-snapshot recovery ---- + + async handleAlarm(): Promise { + const now = this.deps.now(); + const live: CtlSocket[] = []; + for (const socket of this.deps.sockets()) { + const attachment = socket.getAttachment(); + if (!attachment) continue; + if (attachment.expiresAt !== undefined && attachment.expiresAt <= now) { + await this.deps.storage.delete(BEARER_PREFIX + attachment.sessionId); + try { + socket.close(1000, "subscription expired; reconnect with a fresh token"); + } catch { + // already closed + } + continue; + } + live.push(socket); + } + if (live.length === 0) return; // idle account: stop the refresh cadence + await this.refreshDirectory(live); + await this.retryUnackedDirectories(live); + this.sendHeartbeat(live); + let earliestExpiry = Number.POSITIVE_INFINITY; + let earliestAckRetry = Number.POSITIVE_INFINITY; + for (const socket of live) { + const attachment = socket.getAttachment(); + if (!attachment) continue; + if (attachment.expiresAt !== undefined && attachment.expiresAt < earliestExpiry) { + earliestExpiry = attachment.expiresAt; + } + if (attachment.ackRetry !== undefined && attachment.ackRetry.nextAt < earliestAckRetry) { + earliestAckRetry = attachment.ackRetry.nextAt; + } + } + await this.deps.scheduleAlarmAt( + Math.min(this.deps.now() + CONTROL_REFRESH_INTERVAL_MS, earliestExpiry), + ); + // Pull the alarm to the earliest due ack rung (ensure-at keeps the min). + if (earliestAckRetry !== Number.POSITIVE_INFINITY) { + await this.deps.scheduleAlarmAt(earliestAckRetry); + } + } + + /** Send one lightweight application heartbeat to every live, handshaken + * socket. Production control sockets have no subscription deadline. */ + private sendHeartbeat(live: CtlSocket[]): void { + const frame = JSON.stringify({ + v: CONTROL_PROTOCOL_VERSION, + type: CONTROL_HEARTBEAT_TYPE, + payload: { at: new Date(this.deps.now()).toISOString() }, + }); + for (const socket of live) { + const attachment = socket.getAttachment(); + if (!attachment || !attachment.helloed + || (attachment.expiresAt !== undefined && attachment.expiresAt <= this.deps.now())) { + continue; + } + try { + socket.send(frame); + } catch { + // Runtime close callbacks own stale socket cleanup. + } + } + } + + /** Resend the LATEST merged directory to every live socket whose retry rung + * is due and which still has not acked the head revision. Ladder offsets: + * 5s, 30s, 2m, 10m after delivery, then hourly; the ladder resets on ack. */ + private async retryUnackedDirectories(live: CtlSocket[]): Promise { + const now = this.deps.now(); + const rev = (await this.deps.storage.get(REV_KEY)) ?? 0; + const broker = await this.deps.storage.get(DIR_KEY); + let frameJson: string | null = null; + for (const socket of live) { + const attachment = socket.getAttachment(); + if (!attachment || attachment.ackRetry === undefined) continue; + if (attachment.ackRetry.nextAt > now) continue; + if ((attachment.lastAckedRev ?? -1) >= rev) { + // Already acked through head; stand down. + delete attachment.ackRetry; + socket.setAttachment(attachment); + continue; + } + if (broker !== undefined + && (attachment.expiresAt === undefined || attachment.expiresAt > now)) { + if (frameJson === null) { + frameJson = JSON.stringify( + directoryFrame(rev, await this.mergedDirectory(broker), rfc3339FromMs(now)), + ); + } + try { + socket.send(frameJson); + } catch { + // Socket already gone; hibernation cleans it up. + } + } + const attempt = attachment.ackRetry.attempt + 1; + const delay = ACK_RETRY_LADDER_MS[attempt] ?? ACK_RETRY_STEADY_MS; + // The resend carried the head revision, so the ladder now tracks it. + attachment.ackRetry = { rev, attempt, nextAt: now + delay }; + socket.setAttachment(attachment); + } + } + + /** Re-fetch discovery with a live socket's token and broadcast what changed. + * Directory facts are account-scoped, so any live socket's token yields the + * same account view. When legacy adapters provide deadlines, the socket with + * the latest one is preferred because it was verified most recently. Control + * sockets omit that field. Endpoint-bound mints never borrow across sockets. */ + private async refreshDirectory(live: CtlSocket[]): Promise { + const holder = await this.freshestBearerHolder(live); + if (holder === null) return; + const fetched = await this.fetchDirectory(holder); + if (fetched === null) return; // upstream down; keep serving cached facts + const { rev, previous, previousRev } = await this.storeDirectory(fetched); + await this.broadcastDirectoryChange(previous, fetched.payload, rev, previousRev, null); + // Recover sockets whose initial snapshot fetch failed. + const now = this.deps.now(); + for (const socket of live) { + const attachment = socket.getAttachment(); + if (!attachment || !attachment.snapshotPending) continue; + if (attachment.expiresAt !== undefined && attachment.expiresAt <= now) continue; + await this.sendDirectory(socket, attachment, rev, fetched.payload); + await this.finishSnapshot(socket, attachment, rev); + } + } + + // ---- Shared internals ---- + + private async freshestBearerHolder(live: CtlSocket[]): Promise { + const attachments = live + .map((socket) => socket.getAttachment()) + .filter((attachment): attachment is CtlAttachment => attachment !== null) + .sort((left, right) => (right.expiresAt ?? Number.POSITIVE_INFINITY) + - (left.expiresAt ?? Number.POSITIVE_INFINITY)); + return attachments[0] ?? null; + } + + /** GET the discovery endpoint with the given connection's token, one + * immediate retry on connection-level failure. Null on any failure. */ + private async fetchDirectory( + attachment: CtlAttachment, + ): Promise<{ revision: number | null; payload: BrokerDirectoryPayload } | null> { + const credentials = decodeStoredCredentials( + await this.deps.storage.get(BEARER_PREFIX + attachment.sessionId), + ); + if (credentials === undefined) return null; + const result = await this.upstreamOnceRetry(DISCOVERY_PATH, { + method: "GET", + // The control-plane socket is authenticated with the account's Stack + // bearer, but it does not hold the endpoint private key needed to sign + // a binding-request proof. Discovery is intentionally account-scoped + // here, so use the legacy namespace compatibility mode. The returned + // directory is still protected by the socket's account bearer and the + // DO's per-account routing, while the app's own namespace remains on + // mutation/relay requests. + headers: upstreamHeaders(credentials, "legacy", false), + }); + if (result === null || result.status < 200 || result.status >= 300) return null; + return directoryPayloadFromDiscovery(result.json); + } + + private async storeDirectory( + fetched: { revision: number | null; payload: BrokerDirectoryPayload }, + ): Promise<{ rev: number; previous: BrokerDirectoryPayload | undefined; previousRev: number }> { + const previous = await this.deps.storage.get(DIR_KEY); + const previousRev = (await this.deps.storage.get(REV_KEY)) ?? 0; + // Upstream revision when it is ahead, otherwise a local bump — and never + // backwards: DO-local overlay changes (confirm-on-hello, revocation) + // advance the account revision past anything the broker has issued yet. + const contentChanged = previous === undefined + || JSON.stringify(previous) !== JSON.stringify(fetched.payload); + const rev = contentChanged + ? Math.max(fetched.revision ?? 0, previousRev + 1) + : previousRev; + if (contentChanged) await this.deps.storage.put(DIR_KEY, fetched.payload); + if (rev !== previousRev) await this.deps.storage.put(REV_KEY, rev); + return { rev, previous, previousRev }; + } + + /** Broadcast a refreshed directory to every snapshotted socket except + * `excludeSessionId` (the one that just received the full body inline). + * Full-directory frames carry the overlay join and a fresh stamp; both + * frame kinds are revision-bearing and arm the recipients' ack ladders. */ + private async broadcastDirectoryChange( + previous: BrokerDirectoryPayload | undefined, + next: BrokerDirectoryPayload, + rev: number, + previousRev: number, + excludeSessionId: string | null, + ): Promise { + if (rev === previousRev) return; + const delta = directoryDelta(previous, next); + if (delta.kind === "none") return; + const now = this.deps.now(); + const frames = delta.kind === "full" + ? [JSON.stringify(directoryFrame(rev, await this.mergedDirectory(next), rfc3339FromMs(now)))] + : delta.updates.map((update) => JSON.stringify( + hintUpdateFrame(rev, update.endpointId, update.homeRelayUrl, update.updatedAt), + )); + for (const socket of this.deps.sockets()) { + const attachment = socket.getAttachment(); + if (!attachment) continue; + if (excludeSessionId !== null && attachment.sessionId === excludeSessionId) continue; + if (!this.deliverable(attachment, now)) continue; + let delivered = false; + for (const json of frames) { + try { + socket.send(json); + delivered = true; + } catch { + // Socket already gone; hibernation cleans it up. + } + } + if (delivered) await this.markAckPending(socket, attachment, rev); + } + } + + /** Deltas are deliverable only to sockets that finished their snapshot and + * have not passed an adapter-provided lifecycle cutoff. */ + private deliverable(attachment: CtlAttachment, now: number): boolean { + return (attachment.expiresAt === undefined || attachment.expiresAt > now) + && attachment.helloed === true + && attachment.snapshotPending !== true; + } + + private sendFrame(socket: CtlSocket, attachment: CtlAttachment, frame: unknown): void { + if (attachment.expiresAt !== undefined && attachment.expiresAt <= this.deps.now()) return; + try { + socket.send(JSON.stringify(frame)); + } catch { + // Socket already gone; hibernation cleans it up. + } + } + + private async upstreamOnceRetry( + path: string, + init: CtlUpstreamInit, + ): Promise { + try { + return await this.deps.upstream(path, init); + } catch { + // ONE immediate retry on connection-level failure only (an HTTP error + // status resolves and is never retried here). + try { + return await this.deps.upstream(path, init); + } catch { + return null; + } + } + } +} + +function isPlausibleRelayUrl(value: string): boolean { + if (!value || value.length > MAX_RELAY_URL_CHARS) return false; + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + return url.protocol === "https:" || url.protocol === "http:"; +} diff --git a/workers/presence/src/controlPlaneDo.ts b/workers/presence/src/controlPlaneDo.ts new file mode 100644 index 000000000000..c022b59e449c --- /dev/null +++ b/workers/presence/src/controlPlaneDo.ts @@ -0,0 +1,200 @@ +// AccountControlPlane Durable Object — one instance per verified Stack user +// (the worker derives the id from the VERIFIED user id, never client input). +// +// Thin adapter: all protocol logic lives in controlPlane.ts (bun-testable); +// this file binds it to workerd — WebSocket hibernation, DO storage, the DO +// alarm, and upstream fetch against the configured Vercel base URL. +// +// Authorization happens in the worker before anything reaches this object +// (same trust model as TeamPresence): the worker verifies the Stack bearer +// token and resolves the account. Control-plane sockets are intentionally +// long-lived; the DO uses ONLY the connecting client's own bearer token for +// upstream calls, stored per-socket and deleted on close. + +import { DurableObject } from "cloudflare:workers"; +import { bearerToken } from "./auth"; +import { + CONTROL_REFRESH_INTERVAL_MS, + ControlPlaneCore, + MAX_CONTROL_SUBSCRIBERS_PER_ACCOUNT, + parseRevocationRequest, + type CtlAttachment, + type CtlSocket, + type CtlStorage, +} from "./controlPlane"; + +export interface ControlPlaneEnv { + /** Vercel web API origin the DO proxies (dev/prod), e.g. https://cmux.com. + * Same optional-with-production-default pattern as STACK_API_URL. */ + CMUX_WEB_BASE_URL?: string; +} + +const PRODUCTION_WEB_BASE_URL = "https://cmux.com"; + +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** Wrap a hibernatable WebSocket as the core's transport-neutral socket. The + * attachment rides serializeAttachment so it survives DO hibernation. */ +function wrapSocket(ws: WebSocket): CtlSocket { + return { + send(data: string): void { + ws.send(data); + }, + close(code?: number, reason?: string): void { + ws.close(code, reason); + }, + getAttachment(): CtlAttachment | null { + try { + const attachment = ws.deserializeAttachment() as CtlAttachment | null; + return attachment && typeof attachment.sessionId === "string" + && (attachment.expiresAt === undefined || typeof attachment.expiresAt === "number") + ? attachment + : null; + } catch { + return null; + } + }, + setAttachment(attachment: CtlAttachment): void { + try { + ws.serializeAttachment(attachment); + } catch { + // attachment write failed; the socket is likely gone + } + }, + }; +} + +export class AccountControlPlane extends DurableObject { + private readonly core = new ControlPlaneCore({ + // DurableObjectStorage's get/put/delete structurally cover CtlStorage; + // single widening cast, same pattern as TeamPresence.syncStorage(). + storage: this.ctx.storage as unknown as CtlStorage, + now: () => Date.now(), + upstream: async (path, init) => { + const base = (this.env.CMUX_WEB_BASE_URL ?? PRODUCTION_WEB_BASE_URL).replace(/\/+$/, ""); + let response: Response; + try { + response = await fetch(`${base}${path}`, { + method: init.method, + headers: init.headers, + ...(init.body !== undefined ? { body: init.body } : {}), + }); + } catch (error) { + // Preserve connection-level failures for the core's one immediate + // retry, while leaving a safe, token-free breadcrumb in the DO tail. + console.error( + `control-plane upstream ${init.method} ${path} network failure`, + String(error).slice(0, 300), + ); + throw error; + } + // A connection-level failure throws out of fetch (the core's retry-once + // trigger); any HTTP response resolves and is never retried. + const json = await response.json().catch(() => null); + if (response.status >= 400) { + // Upstream refusals must be attributable from the worker tail alone; + // clients only ever see the mapped retryable/non-retryable error code. + console.error( + `control-plane upstream ${init.method} ${path} -> ${response.status}`, + JSON.stringify(json)?.slice(0, 300) ?? "", + ); + } + return { status: response.status, json }; + }, + scheduleAlarmAt: (atMs) => this.ensureAlarmAt(atMs), + sockets: () => this.ctx.getWebSockets().map(wrapSocket), + }); + + override async fetch(request: Request): Promise { + // Device revocation, forwarded by the worker with rebuilt headers after + // Stack bearer verification. This DO instance IS the verified account + // scope; the strict-parsed body carries only {endpointId, revoked}. + if (request.method === "POST" + && new URL(request.url).pathname === "/v1/control/devices/revoke") { + if (!request.headers.get("x-control-account-id")?.trim()) { + return json({ error: "account_required" }, 403); + } + let body: unknown; + try { + body = await request.json(); + } catch { + return json({ error: "invalid_request" }, 400); + } + const parsed = parseRevocationRequest(body); + if (parsed === null) return json({ error: "invalid_request" }, 400); + const result = await this.core.handleRevocation(parsed); + return json({ ok: true, ...result }, 200); + } + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return json({ error: "websocket_required" }, 400); + } + // Verified by the worker; never client input. + const accountId = request.headers.get("x-control-account-id")?.trim(); + if (!accountId) return json({ error: "account_required" }, 403); + // The DO keeps the connection's own bearer for its upstream proxy calls. + const bearer = bearerToken(request); + if (!bearer) return json({ error: "unauthorized" }, 401); + // The web API's native auth requires the refresh token BESIDE the bearer + // (parseNativeStackTokens); without it every upstream proxy call 401s. + const refresh = request.headers.get("x-stack-refresh-token")?.trim() || undefined; + const namespace = request.headers.get("x-cmux-app-namespace")?.trim() || undefined; + + const connected = this.ctx.getWebSockets().filter((ws) => { + const attachment = wrapSocket(ws).getAttachment(); + return attachment !== null; + }).length; + if (connected >= MAX_CONTROL_SUBSCRIBERS_PER_ACCOUNT) { + return json({ error: "too_many_subscribers" }, 429); + } + + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + // Hibernation API: the DO can be evicted while sockets stay connected. + this.ctx.acceptWebSocket(server); + await this.core.handleConnect(wrapSocket(server), { + sessionId: crypto.randomUUID(), + bearer, + ...(refresh ? { refresh } : {}), + ...(namespace ? { namespace } : {}), + }); + return new Response(null, { status: 101, webSocket: client }); + } + + override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + await this.core.handleMessage(wrapSocket(ws), message); + } + + override async webSocketClose(ws: WebSocket): Promise { + await this.core.handleClose(wrapSocket(ws)); + try { + ws.close(); + } catch { + // already closed + } + } + + override async alarm(): Promise { + await this.core.handleAlarm(); + } + + /** Pull the alarm earlier if `due` precedes the currently scheduled one + * (same ensure-at semantics as TeamPresence). The alarm handler reschedules + * the steady CONTROL_REFRESH_INTERVAL_MS cadence itself while sockets are + * connected, so this only ever needs the cheap min(). */ + private async ensureAlarmAt(due: number): Promise { + const current = await this.ctx.storage.getAlarm(); + if (current === null || current > due) { + await this.ctx.storage.setAlarm(due); + } + } +} + +/** Re-exported so wrangler migrations and the worker Env can reference one + * canonical cadence constant from the adapter module. */ +export { CONTROL_REFRESH_INTERVAL_MS }; diff --git a/workers/presence/src/controlPlaneProof.ts b/workers/presence/src/controlPlaneProof.ts new file mode 100644 index 000000000000..6559c74d3a0c --- /dev/null +++ b/workers/presence/src/controlPlaneProof.ts @@ -0,0 +1,139 @@ +// Ed25519 binding-request proof verification, ported to WebCrypto. +// +// Exact port of web/services/iroh/crypto.ts verifyBindingRequestSignature +// (transcript bytes, canonical base64url decode, 5-minute freshness window, +// endpointId-as-raw-public-key), returning a result instead of throwing. +// +// Phase A of the control plane authorizes via the bearer-authenticated socket +// and does NOT require this proof anywhere on the connect or message path; it +// is kept verified-and-tested here for the source-of-truth migration, when the +// DO stops proxying and must check endpoint possession itself. + +const ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; +const BODY_SHA256_RE = /^[0-9a-f]{64}$/; +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; + +/** Same freshness window the broker enforces (crypto.ts: |now - ts| > 5*60). */ +export const BINDING_PROOF_FRESHNESS_WINDOW_SECONDS = 5 * 60; + +export interface BindingRequestProofInput { + readonly bindingId: string; + readonly method: string; + /** Request pathname with leading slashes stripped (routeHandler.ts: + * `new URL(request.url).pathname.replace(/^\/+/, "")`). */ + readonly path: string; + readonly timestampSeconds: number; + /** Lowercase hex SHA-256 of the exact request body bytes (empty body hashes + * to e3b0c442...). */ + readonly bodySha256: string; + /** Canonical base64url (no padding) Ed25519 signature, 64 bytes decoded. */ + readonly signature: string; + /** The endpoint's Ed25519 public key as 64 lowercase hex chars. */ + readonly endpointId: string; + readonly nowSeconds: number; +} + +export type BindingProofVerification = + | { readonly ok: true } + | { readonly ok: false; readonly code: "invalid_binding_request_proof" }; + +const INVALID: BindingProofVerification = { ok: false, code: "invalid_binding_request_proof" }; + +/** The exact signed bytes construction from web/services/iroh/crypto.ts + * bindingRequestTranscript. */ +export function bindingRequestTranscript(input: Omit< + BindingRequestProofInput, + "signature" | "endpointId" | "nowSeconds" +>): Uint8Array { + return new TextEncoder().encode( + `cmux/iroh/binding-request/v1\n${input.bindingId}\n${input.method}\n${input.path}\n${input.timestampSeconds}\n${input.bodySha256}`, + ); +} + +/** Decode canonical base64url (no padding, re-encodes to itself) of an exact + * byte length, mirroring crypto.ts decodeCanonicalBase64url. Null on any + * deviation, including standard base64 with +, /, or = padding. */ +export function decodeCanonicalBase64url( + encoded: string, + expectedLength: number, +): Uint8Array | null { + if (!encoded || !BASE64URL_RE.test(encoded)) return null; + const standard = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padded = standard + "=".repeat((4 - (standard.length % 4)) % 4); + let binary: string; + try { + binary = atob(padded); + } catch { + return null; + } + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + if (bytes.byteLength !== expectedLength) return null; + // Canonicality: re-encoding must reproduce the input (rejects non-zero + // trailing bits and overlong encodings, like the node Buffer round-trip). + if (encodeBase64url(bytes) !== encoded) return null; + return bytes; +} + +export function encodeBase64url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function endpointPublicKeyBytes(endpointId: string): Uint8Array | null { + if (!ENDPOINT_ID_RE.test(endpointId)) return null; + const bytes = new Uint8Array(32); + for (let index = 0; index < 32; index += 1) { + bytes[index] = Number.parseInt(endpointId.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +/** Verify a binding-request proof with the same semantics as the broker: + * safe-integer timestamp inside the freshness window, hex body hash, canonical + * 64-byte base64url signature, Ed25519 over the transcript with the endpoint's + * own key (the endpointId IS the raw public key). Never throws. */ +export async function verifyBindingRequestProof( + input: BindingRequestProofInput, +): Promise { + if ( + !Number.isSafeInteger(input.timestampSeconds) + || Math.abs(input.nowSeconds - input.timestampSeconds) > BINDING_PROOF_FRESHNESS_WINDOW_SECONDS + || !BODY_SHA256_RE.test(input.bodySha256) + ) { + return INVALID; + } + const signature = decodeCanonicalBase64url(input.signature, 64); + if (signature === null) return INVALID; + const publicKeyBytes = endpointPublicKeyBytes(input.endpointId); + if (publicKeyBytes === null) return INVALID; + let valid = false; + try { + const publicKey = await crypto.subtle.importKey( + "raw", + publicKeyBytes as unknown as ArrayBuffer, + "Ed25519", + false, + ["verify"], + ); + valid = await crypto.subtle.verify( + "Ed25519", + publicKey, + signature as unknown as ArrayBuffer, + bindingRequestTranscript(input) as unknown as ArrayBuffer, + ); + } catch { + return INVALID; // not a curve point / runtime without Ed25519 + } + return valid ? { ok: true } : INVALID; +} + +/** SHA-256 of raw bytes as lowercase hex; the body-hash leg of the transcript + * (an empty body yields e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855). */ +export async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes as unknown as ArrayBuffer); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/workers/presence/src/generated/controlPlane.ts b/workers/presence/src/generated/controlPlane.ts new file mode 100644 index 000000000000..5c38e524d2fe --- /dev/null +++ b/workers/presence/src/generated/controlPlane.ts @@ -0,0 +1,344 @@ +export interface CTLACK { + payload: CTLACKPayload; + /** + * directory/hint revision the client has applied; stops the server's retry ladder for + * revisions up to and including it + */ + rev: number; + type: CTLACKType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLACKPayload { + /** + * optional client stamp of when the acked revision was applied + */ + appliedAt?: Date; +} + +export type CTLACKType = "ack"; + +export interface CTLError { + payload: CTLErrorPayload; + type: CTLErrorType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLErrorPayload { + code: string; + message: string; + retryable: boolean; +} + +export type CTLErrorType = "error"; + +export interface CTLDirectory { + payload: CTLDirectoryPayload; + /** + * monotonic account route revision this fact reflects + */ + rev: number; + type: CTLDirectoryType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLDirectoryPayload { + bindings: Binding[]; + grantVerificationKeys: GrantVerificationKey[]; + /** + * server stamp when this directory was issued; anchor of the trust lease + */ + issuedAt: Date; + /** + * per-platform app-version floors; clients below the floor must update before participating + */ + minimumSupportedVersion?: PurpleMinimumSupportedVersion; + relayFleet: string[]; + routeContractVersion: number; + /** + * trust lease duration; clients treat the directory as stale once issuedAt + ttlSeconds + * passes without a re-stamp + */ + ttlSeconds: number; +} + +export interface Binding { + appVersion?: string; + bindingId: string; + capabilities?: string[]; + clientNamespace: string; + deviceId?: null | string; + endpointId: string; + homeRelayUrl?: null | string; + instanceTag?: null | string; + /** + * when this device last confirmed itself over its own control-plane hello + */ + lastConfirmedAt?: Date; + releaseTrack?: ReleaseTrack; + /** + * authorization kill switch, orthogonal to status; peers must deny P2P admission to a + * revoked device + */ + revoked: boolean; + /** + * device lifecycle state from the account overlay; a binding never confirmed by its own + * hello stays seeded + */ + status?: Status; + updatedAt?: Date | null; +} + +export type ReleaseTrack = "nightly" | "stable" | "internal" | "beta" | "appstore" | "dev"; + +/** + * device lifecycle state from the account overlay; a binding never confirmed by its own + * hello stays seeded + */ +export type Status = "active" | "seeded" | "stale" | "retired" | "suspended" | "pending" | "superseded"; + +export interface GrantVerificationKey { + alg: string; + keyId: string; + publicKey: string; +} + +/** + * per-platform app-version floors; clients below the floor must update before participating + */ +export interface PurpleMinimumSupportedVersion { + ios?: string; + mac?: string; +} + +export type CTLDirectoryType = "directory"; + +export interface CTLHelloACK { + payload: CTLHelloACKPayload; + type: CTLHelloACKType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLHelloACKPayload { + /** + * echo of the directory's per-platform version floors so clients get them before the + * directory body + */ + minimumSupportedVersion?: FluffyMinimumSupportedVersion; + /** + * rev the server resumed the delta stream from; null means full snapshot follows + */ + resumedFromRev?: number | null; + /** + * control-plane features this server supports (list overlay, ack tracking, revocation) + */ + serverCapabilities?: string[]; + sessionId: string; +} + +/** + * echo of the directory's per-platform version floors so clients get them before the + * directory body + */ +export interface FluffyMinimumSupportedVersion { + ios?: string; + mac?: string; +} + +export type CTLHelloACKType = "hello_ack"; + +export interface CTLHello { + payload: CTLHelloPayload; + type: CTLHelloType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLHelloPayload { + appVersion?: string; + capabilities?: string[]; + /** + * optional client self-identification; presence of any client-info field confirms the + * device into the account overlay + */ + deviceId?: string; + endpointId: string; + /** + * highest rev this client has on disk; server streams deltas after it, or a full snapshot + * when null/too old + */ + haveRev?: number | null; + platform?: Platform; + releaseTrack?: ReleaseTrack; + wantPasses: boolean; +} + +export type Platform = "mac" | "ios"; + +export type CTLHelloType = "hello"; + +export interface CTLHintUpdate { + payload: CTLHintUpdatePayload; + /** + * monotonic account route revision this fact reflects + */ + rev: number; + type: CTLHintUpdateType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLHintUpdatePayload { + endpointId: string; + homeRelayUrl: string; + updatedAt?: Date | null; +} + +export type CTLHintUpdateType = "hint_update"; + +export interface CTLMintRequest { + payload: CTLMintRequestPayload; + type: CTLMintRequestType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLMintRequestPayload { + endpointId: string; + /** + * Optional signed identity assertion (reserved for the source-of-truth migration; phase A + * authorizes via the bearer-authenticated socket and confirms hints by re-fetching + * discovery) + */ + proof?: PurpleProof; +} + +/** + * Optional signed identity assertion (reserved for the source-of-truth migration; phase A + * authorizes via the bearer-authenticated socket and confirms hints by re-fetching + * discovery) + */ +export interface PurpleProof { + bindingId: string; + /** + * base64 Ed25519 signature by the endpoint key + */ + signature: string; + /** + * RFC3339 issue time; server enforces freshness window + */ + timestamp: string; +} + +export type CTLMintRequestType = "mint_request"; + +export interface CTLPublishHint { + payload: CTLPublishHintPayload; + type: CTLPublishHintType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLPublishHintPayload { + endpointId: string; + homeRelayUrl: string; + /** + * Optional signed identity assertion (reserved for the source-of-truth migration; phase A + * authorizes via the bearer-authenticated socket and confirms hints by re-fetching + * discovery) + */ + proof?: FluffyProof; +} + +/** + * Optional signed identity assertion (reserved for the source-of-truth migration; phase A + * authorizes via the bearer-authenticated socket and confirms hints by re-fetching + * discovery) + */ +export interface FluffyProof { + bindingId: string; + /** + * base64 Ed25519 signature by the endpoint key + */ + signature: string; + /** + * RFC3339 issue time; server enforces freshness window + */ + timestamp: string; +} + +export type CTLPublishHintType = "publish_hint"; + +export interface CTLRelayPasses { + payload: CTLRelayPassesPayload; + /** + * monotonic account route revision this fact reflects + */ + rev: number; + type: CTLRelayPassesType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLRelayPassesPayload { + endpointId: string; + passes: Pass[]; +} + +export interface Pass { + expiresAt: Date; + generation: number; + /** + * server-driven early-refresh point (expiry minus margin) + */ + refreshAfter: Date; + relayUrl: string; + token: string; +} + +export type CTLRelayPassesType = "relay_passes"; + +export interface CTLSnapshotComplete { + payload: CTLSnapshotCompletePayload; + /** + * monotonic account route revision this fact reflects + */ + rev: number; + type: CTLSnapshotCompleteType; + /** + * control-plane protocol version, 1 + */ + v: number; +} + +export interface CTLSnapshotCompletePayload { + /** + * server freshness re-stamp; when a hello's haveRev already matches head this frame alone + * re-arms the directory trust lease without resending the body + */ + issuedAt?: Date; +} + +export type CTLSnapshotCompleteType = "snapshot_complete"; diff --git a/workers/presence/src/index.ts b/workers/presence/src/index.ts index 99681ff12374..609f7b7b390b 100644 --- a/workers/presence/src/index.ts +++ b/workers/presence/src/index.ts @@ -8,6 +8,12 @@ // snapshot first, then online/offline/seen // GET /v1/connectivity/subscribe quiet account route-revision stream // POST /v1/connectivity/invalidate publish one account route revision +// GET /v1/control/socket account control-plane WebSocket: +// revisioned directory/hint/pass facts +// POST /v1/control/devices/revoke flip one device's revoked flag +// ({endpointId, revoked}); the DO +// broadcasts, closes that device's +// sockets, and refuses its mints // POST /v1/replies park one phone inline-notification reply // GET /v1/replies?macDeviceId=… pending replies for one Mac // POST /v1/replies/ack remove processed replies @@ -29,6 +35,8 @@ import { type AuthEnv, } from "./auth"; import { MAX_SUBSCRIBE_AGE_MS, TeamPresence } from "./do"; +import { AccountControlPlane, type ControlPlaneEnv } from "./controlPlaneDo"; +import { parseRevocationRequest } from "./controlPlane"; import { isConnectivityPublisherAuthorized, parseConnectivityInvalidation, @@ -43,10 +51,11 @@ import { parsePhoneReplyAck, } from "./replies"; -export { TeamPresence }; +export { TeamPresence, AccountControlPlane }; -export interface Env extends AuthEnv { +export interface Env extends AuthEnv, ControlPlaneEnv { TEAM_PRESENCE: DurableObjectNamespace; + ACCOUNT_CONTROL_PLANE: DurableObjectNamespace; CONNECTIVITY_INVALIDATION_SECRET?: string; } @@ -108,6 +117,58 @@ export default { return stub.fetch(new Request(request.url, { method: "GET", headers })); } + if (url.pathname === "/v1/control/socket") { + // Account control plane: one WebSocket carrying revisioned facts + // (directory, hint updates, relay passes). Auth is checked on the + // WebSocket upgrade here, and the DO is derived from the VERIFIED user + // id. The socket itself is long-lived; hibernation controls DO memory, + // not the WebSocket lifetime. The DO additionally + // keeps the connection's own bearer (already on the forwarded headers) + // for its upstream broker proxy calls — never its own credentials. + if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405); + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return json({ error: "websocket_required" }, 400); + } + const namespace = request.headers.get("x-cmux-app-namespace")?.trim(); + if (namespace && !/^[A-Za-z0-9._:-]{1,255}$/.test(namespace)) { + return json({ error: "invalid_client_namespace" }, 400); + } + const user = await verifyRequest(request, env); + if (!user) return unauthorized(); + const headers = new Headers(request.headers); + headers.set("x-control-account-id", user.id); + const stub = env.ACCOUNT_CONTROL_PLANE.get( + env.ACCOUNT_CONTROL_PLANE.idFromName(`control:user:${user.id}`), + ); + return stub.fetch(new Request(request.url, { method: "GET", headers })); + } + + if (url.pathname === "/v1/control/devices/revoke") { + // Account-owner device revocation. Same Stack bearer verification as the + // control-plane socket route; the target DO is derived from the VERIFIED + // user id, the forwarded headers are rebuilt from scratch, and only the + // strict-parsed body travels — a client-supplied account id has no + // channel here. + if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405); + const user = await verifyRequest(request, env); + if (!user) return unauthorized(); + const body = await readBoundedJson(request, 1_024); + if (!body.ok) return json({ error: "invalid_request" }, body.status); + const parsed = parseRevocationRequest(body.value); + if (parsed === null) return json({ error: "invalid_request" }, 400); + const headers = new Headers(); + headers.set("x-control-account-id", user.id); + headers.set("content-type", "application/json"); + const stub = env.ACCOUNT_CONTROL_PLANE.get( + env.ACCOUNT_CONTROL_PLANE.idFromName(`control:user:${user.id}`), + ); + return stub.fetch(new Request(request.url, { + method: "POST", + headers, + body: JSON.stringify(parsed), + })); + } + if (url.pathname === "/v1/connectivity/invalidate") { if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405); if (!await isConnectivityPublisherAuthorized( diff --git a/workers/presence/test/controlPlaneFixtures.test.ts b/workers/presence/test/controlPlaneFixtures.test.ts new file mode 100644 index 000000000000..161ab13aafec --- /dev/null +++ b/workers/presence/test/controlPlaneFixtures.test.ts @@ -0,0 +1,61 @@ +// Golden-fixture guard: every wire example in schemas/control-plane/fixtures/ +// must decode into the generated control-plane types and round-trip through +// JSON losslessly. Catches schema/codegen/validator drift in either direction: +// a fixture the decoder rejects, or a decoder that drops/renames fields. + +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { decodeControlFrame, type DecodedControlFrame } from "../src/controlPlane"; + +const FIXTURES_DIR = join(import.meta.dir, "../../../schemas/control-plane/fixtures"); + +const EXPECTED_KINDS: Record = { + "ack.json": "ack", + "control-error.json": "error", + "directory.json": "directory", + "hello-ack.json": "hello_ack", + "hello.json": "hello", + "hint-update.json": "hint_update", + "mint-request.json": "mint_request", + "publish-hint.json": "publish_hint", + "relay-passes.json": "relay_passes", + "snapshot-complete.json": "snapshot_complete", +}; + +describe("control-plane golden fixtures", () => { + const names = readdirSync(FIXTURES_DIR).filter((name) => name.endsWith(".json")).sort(); + + it("covers every frame type in the contract", () => { + // A new fixture without a mapping (or a removed fixture) fails loudly + // instead of silently shrinking coverage. + expect(names).toEqual(Object.keys(EXPECTED_KINDS).sort()); + }); + + for (const name of names) { + it(`parses and round-trips ${name}`, () => { + const original: unknown = JSON.parse(readFileSync(join(FIXTURES_DIR, name), "utf8")); + const decoded = decodeControlFrame(original); + expect(decoded).not.toBeNull(); + expect(decoded?.kind).toBe(EXPECTED_KINDS[name] as DecodedControlFrame["kind"]); + // Lossless round-trip: serializing the typed frame reproduces the + // fixture's JSON value exactly. + expect(JSON.parse(JSON.stringify(decoded?.frame))).toEqual(original); + }); + } + + it("rejects envelope violations the schemas forbid", () => { + const hello: unknown = JSON.parse(readFileSync(join(FIXTURES_DIR, "hello.json"), "utf8")); + const base = hello as Record; + expect(decodeControlFrame({ ...base, v: 2 })).toBeNull(); + expect(decodeControlFrame({ ...base, rev: 7 })).toBeNull(); // hello carries no rev + expect(decodeControlFrame({ ...base, extra: true })).toBeNull(); + expect(decodeControlFrame({ + ...base, + payload: { ...(base.payload as Record), unknownField: 1 }, + })).toBeNull(); + const directory: unknown = JSON.parse(readFileSync(join(FIXTURES_DIR, "directory.json"), "utf8")); + const { rev: _rev, ...directoryWithoutRev } = directory as Record; + expect(decodeControlFrame(directoryWithoutRev)).toBeNull(); // facts require rev + }); +}); diff --git a/workers/presence/test/controlPlaneListAuth.test.ts b/workers/presence/test/controlPlaneListAuth.test.ts new file mode 100644 index 000000000000..dd0b5cb740dc --- /dev/null +++ b/workers/presence/test/controlPlaneListAuth.test.ts @@ -0,0 +1,570 @@ +// listv2 device-authorization overlay on the ControlPlaneCore: seeded overlay +// rows, confirm-on-hello, ack tracking with the alarm-driven retry ladder, +// account-owner revocation, and the freshness lease (issuedAt/ttlSeconds on +// directories; snapshot_complete.issuedAt playing the explicit-freshness +// "current" role on the haveRev fast path — the existing frame was extended +// instead of adding a new frame type, which keeps old clients decoding). + +import { describe, expect, it } from "bun:test"; +import { + ACK_RETRY_LADDER_MS, + ACK_RETRY_STEADY_MS, + BEARER_PREFIX, + CONTROL_SERVER_CAPABILITIES, + ControlPlaneCore, + DEV_PREFIX, + DIR_KEY, + DIRECTORY_TTL_SECONDS, + REV_KEY, + directoryPayloadFromDiscovery, + parseRevocationRequest, + type CtlAttachment, + type CtlSocket, + type CtlUpstreamInit, + type CtlUpstreamResult, + type DeviceOverlay, +} from "../src/controlPlane"; + +const T0 = 1_800_000_000_000; +const T0_SECONDS = Math.floor(T0 / 1000); +const ENDPOINT_A = "a".repeat(64); +const ENDPOINT_B = "b".repeat(64); +const RELAY_1 = "https://usw1.relay.example/"; +const RELAY_2 = "https://use4.relay.example/"; + +function discoveryResponse( + revision: number, + options: { minimumSupportedVersion?: boolean } = {}, +): unknown { + return { + route_contract_version: 1, + revision, + bindings: [ + { + binding_id: "611ffbbb-9f60-4601-ba39-4c241b900497", + device_id: "77116c35-0000-4000-8000-000000000001", + client_namespace: "irx", + tag: "irx", + endpoint_id: ENDPOINT_A, + path_hints: [ + { + kind: "relay_url", + value: RELAY_1, + source: "native", + privacy_scope: "public_internet", + observed_at: "2026-08-26T00:00:00Z", + expires_at: "2026-08-26T00:30:00Z", + }, + ], + last_seen_at: "2026-08-26T00:00:00Z", + }, + ], + relay_fleet: [RELAY_1, RELAY_2], + grant_verification_keys: { + version: 1, + current_kid: "k1", + keys: [{ kid: "k1", alg: "EdDSA", spki_der_base64: "MCowBQYDK2VwAyEA" }], + }, + ...(options.minimumSupportedVersion + ? { minimum_supported_version: { mac: "0.30.0", ios: "1.4.0" } } + : {}), + }; +} + +function mintResponse(endpointId: string): unknown { + return { + endpointId, + relayCredentials: [ + { + relayUrl: RELAY_1, + token: "tok-1", + expiresAt: T0_SECONDS + 300, + refreshAfter: T0_SECONDS + 240, + ttlSeconds: 300, + }, + ], + }; +} + +class FakeSocket implements CtlSocket { + frames: Record[] = []; + closes: { code?: number; reason?: string }[] = []; + private attachment: CtlAttachment | null = null; + + send(data: string): void { + this.frames.push(JSON.parse(data) as Record); + } + + close(code?: number, reason?: string): void { + this.closes.push({ ...(code !== undefined ? { code } : {}), ...(reason !== undefined ? { reason } : {}) }); + } + + getAttachment(): CtlAttachment | null { + return this.attachment ? { ...this.attachment } : null; + } + + setAttachment(attachment: CtlAttachment): void { + this.attachment = { ...attachment }; + } + + types(): string[] { + return this.frames.map((frame) => String(frame.type)); + } + + frame(type: string): Record | undefined { + return this.frames.find((frame) => frame.type === type); + } + + lastFrame(type: string): Record | undefined { + return [...this.frames].reverse().find((frame) => frame.type === type); + } + + countOf(type: string): number { + return this.frames.filter((frame) => frame.type === type).length; + } + + clearFrames(): void { + this.frames = []; + } +} + +type UpstreamHandler = (init: CtlUpstreamInit) => CtlUpstreamResult; + +class Harness { + now = T0; + map = new Map(); + alarms: number[] = []; + socketList: FakeSocket[] = []; + calls: { path: string; init: CtlUpstreamInit }[] = []; + routes = new Map(); + core = new ControlPlaneCore({ + storage: { + get: async (key: string) => this.map.get(key) as T | undefined, + put: async (key: string, value: unknown) => { + this.map.set(key, value); + }, + delete: async (key: string) => this.map.delete(key), + list: async (options: { prefix: string }) => { + const out = new Map(); + for (const [key, value] of this.map) { + if (key.startsWith(options.prefix)) out.set(key, value as T); + } + return out; + }, + }, + now: () => this.now, + upstream: async (path, init) => { + this.calls.push({ path, init }); + const handler = this.routes.get(path); + if (!handler) throw new Error(`no upstream handler for ${path}`); + return handler(init); + }, + scheduleAlarmAt: async (atMs) => { + this.alarms.push(atMs); + }, + sockets: () => [...this.socketList], + }); + + serveDiscovery(response: () => unknown): void { + this.routes.set("/api/devices/iroh", () => ({ status: 200, json: response() })); + } + + serveMint(handler: UpstreamHandler): void { + this.routes.set("/api/relay/token", handler); + } + + async connect( + sessionId: string, + options: { expiresInMs?: number; namespace?: string } = {}, + ): Promise { + const socket = new FakeSocket(); + this.socketList.push(socket); + await this.core.handleConnect(socket, { + sessionId, + expiresAt: this.now + (options.expiresInMs ?? 15 * 60_000), + bearer: `token-${sessionId}`, + ...(options.namespace !== undefined ? { namespace: options.namespace } : {}), + }); + return socket; + } + + async send(socket: FakeSocket, frame: unknown): Promise { + await this.core.handleMessage(socket, JSON.stringify(frame)); + } + + async hello(socket: FakeSocket, payload: Record): Promise { + await this.send(socket, { v: 1, type: "hello", payload }); + } + + overlay(endpointId: string): DeviceOverlay | undefined { + return this.map.get(DEV_PREFIX + endpointId) as DeviceOverlay | undefined; + } +} + +describe("listv2 seeded overlay", () => { + it("creates a seeded overlay row on first directory and emits it in the bindings", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_B, haveRev: null, wantPasses: false }); + + // The binding's first sighting materialized its overlay row in storage… + expect(harness.overlay(ENDPOINT_A)).toEqual({ status: "seeded", revoked: false }); + // …and the emitted directory carries the join (revoked is REQUIRED). + const directory = socket.frame("directory") as { payload: { bindings: Record[] } }; + expect(directory.payload.bindings[0]).toMatchObject({ + endpointId: ENDPOINT_A, + status: "seeded", + revoked: false, + }); + }); + + it("stamps issuedAt + ttlSeconds on every outbound directory", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_B, haveRev: null, wantPasses: false }); + const payload = (socket.frame("directory") as { payload: Record }).payload; + expect(payload.issuedAt).toBe(new Date(T0).toISOString()); + expect(payload.ttlSeconds).toBe(DIRECTORY_TTL_SECONDS); + }); +}); + +describe("confirm-on-hello", () => { + it("flips seeded -> active, records version/track/capabilities, bumps rev, and broadcasts", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + + const viewer = await harness.connect("viewer"); + await harness.hello(viewer, { endpointId: ENDPOINT_B, haveRev: null, wantPasses: false }); + expect(harness.overlay(ENDPOINT_A)?.status).toBe("seeded"); + viewer.clearFrames(); + + const mac = await harness.connect("mac"); + await harness.hello(mac, { + endpointId: ENDPOINT_A, + haveRev: null, + wantPasses: false, + deviceId: "77116c35-0000-4000-8000-000000000001", + platform: "mac", + appVersion: "1.2.3", + releaseTrack: "internal", + capabilities: ["cmux.irx.v1"], + }); + + // Overlay recorded the confirmation. + expect(harness.overlay(ENDPOINT_A)).toEqual({ + status: "active", + revoked: false, + appVersion: "1.2.3", + releaseTrack: "internal", + capabilities: ["cmux.irx.v1"], + lastConfirmedAt: new Date(T0).toISOString(), + deviceId: "77116c35-0000-4000-8000-000000000001", + }); + + // The overlay change is revision-bearing: 42 (broker) -> 43 (local bump). + expect(harness.map.get(REV_KEY)).toBe(43); + + // The peer got the broadcast with the now-active binding… + expect(viewer.types()).toEqual(["directory"]); + const broadcast = viewer.frame("directory") as { rev: number; payload: { bindings: Record[] } }; + expect(broadcast.rev).toBe(43); + expect(broadcast.payload.bindings[0]).toMatchObject({ + endpointId: ENDPOINT_A, + status: "active", + revoked: false, + appVersion: "1.2.3", + releaseTrack: "internal", + capabilities: ["cmux.irx.v1"], + }); + + // …while the confirming socket saw exactly ONE directory (its snapshot, + // already reflecting its own confirmation at the bumped rev). + expect(mac.countOf("directory")).toBe(1); + const snapshot = mac.frame("directory") as { rev: number; payload: { bindings: Record[] } }; + expect(snapshot.rev).toBe(43); + expect(snapshot.payload.bindings[0]).toMatchObject({ status: "active" }); + }); + + it("skips the confirmation (not the hello) on oversized client info", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + const socket = await harness.connect("s1"); + await harness.hello(socket, { + endpointId: ENDPOINT_A, + haveRev: null, + wantPasses: false, + appVersion: "x".repeat(65), // over MAX_APP_VERSION_CHARS + }); + expect(socket.types()).toEqual(["hello_ack", "directory", "snapshot_complete"]); + expect(harness.overlay(ENDPOINT_A)?.status).toBe("seeded"); + expect(harness.map.get(REV_KEY)).toBe(42); // no bump + }); + + it("emits a confirmed device the upstream view omits (synthesized), until the TTL lapses", async () => { + const harness = new Harness(); + // Upstream only ever lists ENDPOINT_A; ENDPOINT_B exists solely through + // its own confirmed hello (namespace-filtered upstream views, upstream + // registration lag, caller self-exclusion all look like this). + harness.serveDiscovery(() => discoveryResponse(42)); + + const socket = await harness.connect("s1", { namespace: "dev.cmux.ios.lsta" }); + await harness.hello(socket, { + endpointId: ENDPOINT_B, + haveRev: null, + wantPasses: false, + deviceId: "device-b", + platform: "ios", + appVersion: "1.2+34", + }); + + const directory = socket.frame("directory") as { + payload: { bindings: Record[] }; + }; + const synthesized = directory.payload.bindings.find( + (binding) => binding.endpointId === ENDPOINT_B, + ); + expect(synthesized).toMatchObject({ + bindingId: `ctl-hello:${ENDPOINT_B}`, + clientNamespace: "dev.cmux.ios.lsta", + deviceId: "device-b", + status: "active", + revoked: false, + appVersion: "1.2+34", + }); + + // Past the directory TTL with no re-confirmation the synthesized entry + // drops back out: an upstream deletion cannot outlive the trust lease. + harness.now = T0 + DIRECTORY_TTL_SECONDS * 1000 + 60_000; + const later = await harness.connect("s2"); + await harness.hello(later, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + const laterDirectory = later.frame("directory") as { + payload: { bindings: Record[] }; + }; + expect( + laterDirectory.payload.bindings.some((binding) => binding.endpointId === ENDPOINT_B), + ).toBe(false); + }); +}); + +describe("ack tracking and the alarm retry ladder", () => { + it("resends the latest directory at 5s/30s/2m/10m then hourly until acked, and stands down on ack", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + const socket = await harness.connect("s1", { expiresInMs: 48 * 3_600_000 }); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + + expect(socket.countOf("directory")).toBe(1); + expect(socket.getAttachment()?.ackRetry).toEqual({ + rev: 42, + attempt: 0, + nextAt: T0 + (ACK_RETRY_LADDER_MS[0] ?? 0), + }); + // The first rung pulled the alarm forward. + expect(harness.alarms).toContain(T0 + (ACK_RETRY_LADDER_MS[0] ?? 0)); + + // Not due yet: no resend. + harness.now = T0 + 4_999; + await harness.core.handleAlarm(); + expect(socket.countOf("directory")).toBe(1); + + // Climb the ladder: 5s, +30s, +2m, +10m, then hourly (twice to prove the + // steady state repeats). Each retry resends the LATEST directory, never a + // historical delta. + const rungs = [ + ACK_RETRY_LADDER_MS[0] ?? 0, + ACK_RETRY_LADDER_MS[1] ?? 0, + ACK_RETRY_LADDER_MS[2] ?? 0, + ACK_RETRY_LADDER_MS[3] ?? 0, + ACK_RETRY_STEADY_MS, + ACK_RETRY_STEADY_MS, + ]; + let at = T0; + for (const [index, delay] of rungs.entries()) { + at += delay; + harness.now = at; + await harness.core.handleAlarm(); + expect(socket.countOf("directory")).toBe(2 + index); + const expectedNext = at + (ACK_RETRY_LADDER_MS[index + 1] ?? ACK_RETRY_STEADY_MS); + expect(socket.getAttachment()?.ackRetry).toEqual({ + rev: 42, + attempt: index + 1, + nextAt: expectedNext, + }); + const resent = socket.lastFrame("directory") as { rev: number; payload: Record }; + expect(resent.rev).toBe(42); + expect(resent.payload.issuedAt).toBe(new Date(at).toISOString()); // fresh stamp per resend + } + + // Ack clears the ladder and mirrors into the device overlay. + await harness.send(socket, { v: 1, type: "ack", rev: 42, payload: {} }); + expect(socket.getAttachment()?.ackRetry).toBeUndefined(); + expect(socket.getAttachment()?.lastAckedRev).toBe(42); + expect(harness.overlay(ENDPOINT_A)?.lastAckedRev).toBe(42); + + const sent = socket.countOf("directory"); + harness.now = at + ACK_RETRY_STEADY_MS; + await harness.core.handleAlarm(); + expect(socket.countOf("directory")).toBe(sent); // no more resends + + // Stale acks are fine and ignored below the watermark. + await harness.send(socket, { v: 1, type: "ack", rev: 41, payload: {} }); + expect(socket.getAttachment()?.lastAckedRev).toBe(42); + }); + + it("does not arm ack tracking on the haveRev fast path (nothing new was delivered)", async () => { + const harness = new Harness(); + const seeded = directoryPayloadFromDiscovery(discoveryResponse(42)); + harness.map.set(REV_KEY, 42); + harness.map.set(DIR_KEY, seeded?.payload); + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: 42, wantPasses: false }); + expect(socket.types()).toEqual(["hello_ack", "snapshot_complete"]); + expect(socket.getAttachment()?.ackRetry).toBeUndefined(); + }); +}); + +describe("device revocation", () => { + async function snapshotted( + harness: Harness, + sessionId: string, + endpointId: string, + ): Promise { + const socket = await harness.connect(sessionId); + await harness.hello(socket, { endpointId, haveRev: null, wantPasses: false }); + socket.clearFrames(); + return socket; + } + + it("broadcasts the revoked directory, closes the device's sockets, refuses mint, and un-revokes", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + harness.serveMint(() => ({ status: 200, json: mintResponse(ENDPOINT_A) })); + + const mac = await snapshotted(harness, "mac", ENDPOINT_A); + const phone = await snapshotted(harness, "phone", ENDPOINT_B); + + // Pre-revocation mint works. + await harness.send(mac, { v: 1, type: "mint_request", payload: { endpointId: ENDPOINT_A } }); + expect(mac.types()).toEqual(["relay_passes"]); + mac.clearFrames(); + + const result = await harness.core.handleRevocation({ endpointId: ENDPOINT_A, revoked: true }); + expect(result).toEqual({ rev: 43, changed: true, revoked: true }); + + // Immediate broadcast: the peer sees the revoked row at the bumped rev. + const broadcast = phone.frame("directory") as { rev: number; payload: { bindings: Record[] } }; + expect(broadcast.rev).toBe(43); + expect(broadcast.payload.bindings[0]).toMatchObject({ endpointId: ENDPOINT_A, revoked: true }); + + // The revoked device's socket was closed 1008 "revoked" and its bearer + // deleted; the peer's socket stayed open. + expect(mac.closes).toEqual([{ code: 1008, reason: "revoked" }]); + expect(harness.map.has(`${BEARER_PREFIX}mac`)).toBe(false); + expect(phone.closes).toHaveLength(0); + + // A reconnecting revoked device is accepted and may see the list, but its + // mint is refused (non-retryable) — here via hello wantPasses. + const back = await harness.connect("mac2"); + await harness.hello(back, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: true }); + expect(back.types()).toEqual(["hello_ack", "directory", "error", "snapshot_complete"]); + const refusal = back.frame("error") as { payload: Record }; + expect(refusal.payload).toEqual({ + code: "mint_revoked", + message: "device revoked for this account", + retryable: false, + }); + const seen = back.frame("directory") as { payload: { bindings: Record[] } }; + expect(seen.payload.bindings[0]).toMatchObject({ endpointId: ENDPOINT_A, revoked: true }); + // Ack tracking armed normally for the delivered snapshot. + expect(back.getAttachment()?.ackRetry).toMatchObject({ rev: 43 }); + back.clearFrames(); + + // Explicit mint_request is refused too. + await harness.send(back, { v: 1, type: "mint_request", payload: { endpointId: ENDPOINT_A } }); + expect(back.frame("error")?.payload).toMatchObject({ code: "mint_revoked", retryable: false }); + back.clearFrames(); + + // Revoking again is idempotent: no bump, no broadcast. + phone.clearFrames(); + const repeat = await harness.core.handleRevocation({ endpointId: ENDPOINT_A, revoked: true }); + expect(repeat).toEqual({ rev: 43, changed: false, revoked: true }); + expect(phone.frames).toHaveLength(0); + + // Un-revoke: bump + broadcast, and minting works again. + const restore = await harness.core.handleRevocation({ endpointId: ENDPOINT_A, revoked: false }); + expect(restore).toEqual({ rev: 44, changed: true, revoked: false }); + expect((phone.frame("directory") as { rev: number }).rev).toBe(44); + expect( + (phone.frame("directory") as { payload: { bindings: Record[] } }) + .payload.bindings[0], + ).toMatchObject({ revoked: false, status: "seeded" }); // status untouched by revocation (orthogonal) + back.clearFrames(); + await harness.send(back, { v: 1, type: "mint_request", payload: { endpointId: ENDPOINT_A } }); + expect(back.types()).toEqual(["relay_passes"]); + }); + + it("revoking a never-seen endpoint materializes a seeded row so the flag sticks", async () => { + const harness = new Harness(); + const result = await harness.core.handleRevocation({ endpointId: ENDPOINT_B, revoked: true }); + expect(result.changed).toBe(true); + expect(harness.overlay(ENDPOINT_B)).toEqual({ status: "seeded", revoked: true }); + }); + + it("parseRevocationRequest is strict", () => { + expect(parseRevocationRequest({ endpointId: ENDPOINT_A, revoked: true })) + .toEqual({ endpointId: ENDPOINT_A, revoked: true }); + expect(parseRevocationRequest({ endpointId: ENDPOINT_A, revoked: false })) + .toEqual({ endpointId: ENDPOINT_A, revoked: false }); + expect(parseRevocationRequest(null)).toBeNull(); + expect(parseRevocationRequest({ endpointId: ENDPOINT_A })).toBeNull(); + expect(parseRevocationRequest({ endpointId: ENDPOINT_A, revoked: "true" })).toBeNull(); + expect(parseRevocationRequest({ endpointId: "", revoked: true })).toBeNull(); + expect(parseRevocationRequest({ endpointId: "x".repeat(129), revoked: true })).toBeNull(); + expect(parseRevocationRequest({ endpointId: ENDPOINT_A, revoked: true, accountId: "evil" })) + .toBeNull(); + }); +}); + +describe("freshness lease and the snapshot_complete 'current' role", () => { + it("re-stamps issuedAt through snapshot_complete on the haveRev fast path", async () => { + const harness = new Harness(); + const seeded = directoryPayloadFromDiscovery(discoveryResponse(42, { minimumSupportedVersion: true })); + harness.map.set(REV_KEY, 42); + harness.map.set(DIR_KEY, seeded?.payload); + + harness.now = T0 + 12 * 3_600_000; // reconnect much later + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: 42, wantPasses: false }); + + // No directory body — the freshness re-stamp alone re-arms the lease. + // (Design note: the spec's `current` frame rides the existing + // snapshot_complete envelope, extended with issuedAt, because the protocol + // already expressed "your haveRev is head" this way — less invasive than a + // new frame type.) + expect(socket.types()).toEqual(["hello_ack", "snapshot_complete"]); + const complete = socket.frame("snapshot_complete") as { rev: number; payload: Record }; + expect(complete.rev).toBe(42); + expect(complete.payload.issuedAt).toBe(new Date(harness.now).toISOString()); + + // hello_ack advertises server capabilities and echoes the version floors + // from the cached directory, so the client holds them pre-body. + expect(socket.frame("hello_ack")?.payload).toEqual({ + sessionId: "s1", + resumedFromRev: 42, + serverCapabilities: [...CONTROL_SERVER_CAPABILITIES], + minimumSupportedVersion: { mac: "0.30.0", ios: "1.4.0" }, + }); + }); + + it("carries minimumSupportedVersion inside the directory when the broker publishes it", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42, { minimumSupportedVersion: true })); + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + const payload = (socket.frame("directory") as { payload: Record }).payload; + expect(payload.minimumSupportedVersion).toEqual({ mac: "0.30.0", ios: "1.4.0" }); + }); +}); diff --git a/workers/presence/test/controlPlaneProof.test.ts b/workers/presence/test/controlPlaneProof.test.ts new file mode 100644 index 000000000000..6dbc0be39951 --- /dev/null +++ b/workers/presence/test/controlPlaneProof.test.ts @@ -0,0 +1,186 @@ +// Ed25519 binding-request proof verification (controlPlaneProof.ts), the +// WebCrypto port of web/services/iroh/crypto.ts verifyBindingRequestSignature. +// Keys are generated in-test via WebCrypto; no fixtures, no secrets. + +import { beforeAll, describe, expect, it } from "bun:test"; +import { + bindingRequestTranscript, + decodeCanonicalBase64url, + encodeBase64url, + sha256Hex, + verifyBindingRequestProof, +} from "../src/controlPlaneProof"; + +const NOW_SECONDS = 1_800_000_000; +const BINDING_ID = "611ffbbb-9f60-4601-ba39-4c241b900497"; +const OTHER_BINDING_ID = "e1b78ec4-7b2e-4077-88a4-ec4da794a9c6"; +const EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +interface TestEndpoint { + privateKey: CryptoKey; + endpointId: string; +} + +async function generateEndpoint(): Promise { + const pair = (await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey)); + const endpointId = [...raw].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return { privateKey: pair.privateKey, endpointId }; +} + +async function signProof( + endpoint: TestEndpoint, + input: { + bindingId: string; + method: string; + path: string; + timestampSeconds: number; + bodySha256: string; + }, +): Promise { + const signature = new Uint8Array(await crypto.subtle.sign( + "Ed25519", + endpoint.privateKey, + bindingRequestTranscript(input) as unknown as ArrayBuffer, + )); + return encodeBase64url(signature); +} + +const REQUEST = { + bindingId: BINDING_ID, + method: "POST", + path: "api/relay/token", + timestampSeconds: NOW_SECONDS, + bodySha256: EMPTY_BODY_SHA256, +}; + +describe("verifyBindingRequestProof", () => { + let endpoint: TestEndpoint; + let stranger: TestEndpoint; + + beforeAll(async () => { + endpoint = await generateEndpoint(); + stranger = await generateEndpoint(); + }); + + it("accepts a fresh, correctly signed proof", async () => { + const signature = await signProof(endpoint, REQUEST); + expect(await verifyBindingRequestProof({ + ...REQUEST, + signature, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS, + })).toEqual({ ok: true }); + }); + + it("accepts the freshness-window boundary and rejects one second past it", async () => { + const signature = await signProof(endpoint, REQUEST); + expect(await verifyBindingRequestProof({ + ...REQUEST, + signature, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS + 5 * 60, + })).toEqual({ ok: true }); + expect(await verifyBindingRequestProof({ + ...REQUEST, + signature, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS + 5 * 60 + 1, + })).toEqual({ ok: false, code: "invalid_binding_request_proof" }); + }); + + it("rejects an expired proof (client clock far behind)", async () => { + const stale = { ...REQUEST, timestampSeconds: NOW_SECONDS - 6 * 60 }; + const signature = await signProof(endpoint, stale); + expect(await verifyBindingRequestProof({ + ...stale, + signature, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS, + })).toEqual({ ok: false, code: "invalid_binding_request_proof" }); + }); + + it("rejects a proof signed by the wrong key", async () => { + const signature = await signProof(stranger, REQUEST); + expect(await verifyBindingRequestProof({ + ...REQUEST, + signature, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS, + })).toEqual({ ok: false, code: "invalid_binding_request_proof" }); + }); + + it("rejects a proof whose bindingId was swapped after signing", async () => { + const signature = await signProof(endpoint, REQUEST); + expect(await verifyBindingRequestProof({ + ...REQUEST, + bindingId: OTHER_BINDING_ID, + signature, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS, + })).toEqual({ ok: false, code: "invalid_binding_request_proof" }); + }); + + it("rejects a tampered body hash", async () => { + const signature = await signProof(endpoint, REQUEST); + expect(await verifyBindingRequestProof({ + ...REQUEST, + bodySha256: await sha256Hex(new TextEncoder().encode('{"endpointId":"evil"}')), + signature, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS, + })).toEqual({ ok: false, code: "invalid_binding_request_proof" }); + }); + + it("rejects non-canonical signature encodings and malformed inputs", async () => { + const signature = await signProof(endpoint, REQUEST); + const padded = `${signature}==`; + for (const bad of [padded, signature.slice(0, 40), "", "!"]) { + expect(await verifyBindingRequestProof({ + ...REQUEST, + signature: bad, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS, + })).toEqual({ ok: false, code: "invalid_binding_request_proof" }); + } + // endpointId must be 64 lowercase hex chars (the raw public key). + expect(await verifyBindingRequestProof({ + ...REQUEST, + signature, + endpointId: endpoint.endpointId.toUpperCase(), + nowSeconds: NOW_SECONDS, + })).toEqual({ ok: false, code: "invalid_binding_request_proof" }); + // Non-integer timestamp. + expect(await verifyBindingRequestProof({ + ...REQUEST, + timestampSeconds: NOW_SECONDS + 0.5, + signature, + endpointId: endpoint.endpointId, + nowSeconds: NOW_SECONDS, + })).toEqual({ ok: false, code: "invalid_binding_request_proof" }); + }); +}); + +describe("decodeCanonicalBase64url", () => { + it("round-trips canonical encodings and rejects the rest", () => { + const bytes = new Uint8Array(64).fill(0xff); // encodes to "__..." in base64url + const encoded = encodeBase64url(bytes); + expect(encoded).toContain("_"); + expect(decodeCanonicalBase64url(encoded, 64)).toEqual(bytes); + expect(decodeCanonicalBase64url(encoded, 32)).toBeNull(); // wrong length + expect(decodeCanonicalBase64url(`${encoded}=`, 64)).toBeNull(); // padding + expect(decodeCanonicalBase64url(encoded.replace(/_/g, "/"), 64)).toBeNull(); // std alphabet + }); +}); + +describe("bindingRequestTranscript", () => { + it("builds the exact broker signed-bytes construction", () => { + const transcript = bindingRequestTranscript(REQUEST); + expect(new TextDecoder().decode(transcript)).toBe( + `cmux/iroh/binding-request/v1\n${BINDING_ID}\nPOST\napi/relay/token\n${NOW_SECONDS}\n${EMPTY_BODY_SHA256}`, + ); + }); +}); diff --git a/workers/presence/test/controlPlaneStreaming.test.ts b/workers/presence/test/controlPlaneStreaming.test.ts new file mode 100644 index 000000000000..a8bc156a324f --- /dev/null +++ b/workers/presence/test/controlPlaneStreaming.test.ts @@ -0,0 +1,672 @@ +// ControlPlaneCore fact streaming against mocked upstream fetch: snapshot +// ordering, the haveRev fast path, mint retry-once, publish_hint fan-out, the +// alarm-driven re-fetch broadcast, and failure/recovery behavior. + +import { describe, expect, it } from "bun:test"; +import { + BEARER_PREFIX, + CONTROL_REFRESH_INTERVAL_MS, + CONTROL_SERVER_CAPABILITIES, + ControlPlaneCore, + DIR_KEY, + DIRECTORY_TTL_SECONDS, + HINT_CONFIRM_DELAY_MS, + REV_KEY, + SNAPSHOT_RETRY_DELAY_MS, + directoryPayloadFromDiscovery, + type CtlAttachment, + type CtlSocket, + type CtlUpstreamInit, + type CtlUpstreamResult, +} from "../src/controlPlane"; + +const T0 = 1_800_000_000_000; +const T0_SECONDS = Math.floor(T0 / 1000); +const ENDPOINT_A = "a".repeat(64); +const ENDPOINT_B = "b".repeat(64); +const RELAY_1 = "https://usw1.relay.example/"; +const RELAY_2 = "https://use4.relay.example/"; + +function discoveryResponse( + revision: number, + options: { + homeRelayUrl?: string; + extraBinding?: boolean; + } = {}, +): unknown { + const bindings: unknown[] = [ + { + binding_id: "611ffbbb-9f60-4601-ba39-4c241b900497", + device_id: "77116c35-0000-4000-8000-000000000001", + app_instance_id: "app-1", + client_namespace: "irx", + tag: "irx", + platform: "mac", + display_name: "Studio", + endpoint_id: ENDPOINT_A, + identity_generation: 1, + pairing_enabled: true, + capabilities: ["cmux.irx.v1"], + path_hints: [ + { + kind: "relay_url", + value: options.homeRelayUrl ?? RELAY_1, + source: "native", + privacy_scope: "public_internet", + observed_at: "2026-08-26T00:00:00Z", + expires_at: "2026-08-26T00:30:00Z", + }, + ], + last_seen_at: "2026-08-26T00:00:00Z", + }, + ]; + if (options.extraBinding) { + bindings.push({ + binding_id: "e1b78ec4-7b2e-4077-88a4-ec4da794a9c6", + device_id: "77116c35-0000-4000-8000-000000000002", + client_namespace: "irx", + tag: "irx", + endpoint_id: ENDPOINT_B, + path_hints: [], + last_seen_at: "2026-08-26T00:05:00Z", + }); + } + return { + route_contract_version: 1, + revision, + bindings, + relay_fleet: [RELAY_1, RELAY_2], + lan_rendezvous: { generation: 1, key: "unused" }, + grant_verification_keys: { + version: 1, + current_kid: "k1", + keys: [{ kid: "k1", alg: "EdDSA", spki_der_base64: "MCowBQYDK2VwAyEA" }], + }, + }; +} + +function mintResponse(endpointId: string): unknown { + return { + endpointId, + relayCredentials: [ + { + relayUrl: RELAY_1, + token: "tok-1", + expiresAt: T0_SECONDS + 300, + refreshAfter: T0_SECONDS + 240, + ttlSeconds: 300, + }, + { + relayUrl: RELAY_2, + token: "tok-2", + expiresAt: T0_SECONDS + 300, + refreshAfter: T0_SECONDS + 240, + ttlSeconds: 300, + }, + ], + policy: {}, + preference: {}, + preferenceRevision: 1, + }; +} + +class FakeSocket implements CtlSocket { + frames: Record[] = []; + closes: { code?: number; reason?: string }[] = []; + private attachment: CtlAttachment | null = null; + + send(data: string): void { + this.frames.push(JSON.parse(data) as Record); + } + + close(code?: number, reason?: string): void { + this.closes.push({ ...(code !== undefined ? { code } : {}), ...(reason !== undefined ? { reason } : {}) }); + } + + getAttachment(): CtlAttachment | null { + return this.attachment ? { ...this.attachment } : null; + } + + setAttachment(attachment: CtlAttachment): void { + this.attachment = { ...attachment }; + } + + types(): string[] { + return this.frames.map((frame) => String(frame.type)); + } + + frame(type: string): Record | undefined { + return this.frames.find((frame) => frame.type === type); + } + + clearFrames(): void { + this.frames = []; + } +} + +type UpstreamHandler = (init: CtlUpstreamInit) => CtlUpstreamResult; + +class Harness { + now = T0; + map = new Map(); + alarms: number[] = []; + socketList: FakeSocket[] = []; + calls: { path: string; init: CtlUpstreamInit }[] = []; + routes = new Map(); + core = new ControlPlaneCore({ + storage: { + get: async (key: string) => this.map.get(key) as T | undefined, + put: async (key: string, value: unknown) => { + this.map.set(key, value); + }, + delete: async (key: string) => this.map.delete(key), + list: async (options: { prefix: string }) => { + const out = new Map(); + for (const [key, value] of this.map) { + if (key.startsWith(options.prefix)) out.set(key, value as T); + } + return out; + }, + }, + now: () => this.now, + upstream: async (path, init) => { + this.calls.push({ path, init }); + const handler = this.routes.get(path); + if (!handler) throw new Error(`no upstream handler for ${path}`); + return handler(init); + }, + scheduleAlarmAt: async (atMs) => { + this.alarms.push(atMs); + }, + sockets: () => [...this.socketList], + }); + + serveDiscovery(response: () => unknown): void { + this.routes.set("/api/devices/iroh", () => ({ status: 200, json: response() })); + } + + serveMint(handler: UpstreamHandler): void { + this.routes.set("/api/relay/token", handler); + } + + async connect(sessionId: string, namespace?: string): Promise { + const socket = new FakeSocket(); + this.socketList.push(socket); + await this.core.handleConnect(socket, { + sessionId, + expiresAt: this.now + 15 * 60_000, + bearer: `token-${sessionId}`, + ...(namespace ? { namespace } : {}), + }); + return socket; + } + + async connectLongLived(sessionId: string): Promise { + const socket = new FakeSocket(); + this.socketList.push(socket); + await this.core.handleConnect(socket, { + sessionId, + bearer: `token-${sessionId}`, + }); + return socket; + } + + async send(socket: FakeSocket, frame: unknown): Promise { + await this.core.handleMessage(socket, JSON.stringify(frame)); + } + + async hello( + socket: FakeSocket, + payload: { endpointId: string; haveRev?: number | null; wantPasses: boolean }, + ): Promise { + await this.send(socket, { v: 1, type: "hello", payload }); + } + + discoveryCalls(): { path: string; init: CtlUpstreamInit }[] { + return this.calls.filter((call) => call.path === "/api/devices/iroh"); + } + + mintCalls(): { path: string; init: CtlUpstreamInit }[] { + return this.calls.filter((call) => call.path === "/api/relay/token"); + } +} + +describe("hello fact streaming", () => { + it("streams hello_ack -> directory -> relay_passes -> snapshot_complete in order", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + harness.serveMint(() => ({ status: 200, json: mintResponse(ENDPOINT_A) })); + + const socket = await harness.connect("s1", "irx"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: true }); + + expect(socket.types()).toEqual(["hello_ack", "directory", "relay_passes", "snapshot_complete"]); + expect(socket.frame("hello_ack")?.payload).toEqual({ + sessionId: "s1", + resumedFromRev: null, + serverCapabilities: [...CONTROL_SERVER_CAPABILITIES], + }); + + const directory = socket.frame("directory") as { rev: number; payload: Record }; + expect(directory.rev).toBe(42); + expect(directory.payload.routeContractVersion).toBe(1); + expect(directory.payload.relayFleet).toEqual([RELAY_1, RELAY_2]); + expect(directory.payload.grantVerificationKeys).toEqual([ + { keyId: "k1", alg: "EdDSA", publicKey: "MCowBQYDK2VwAyEA" }, + ]); + // Freshness lease stamped per outbound directory. + expect(directory.payload.issuedAt).toBe(new Date(T0).toISOString()); + expect(directory.payload.ttlSeconds).toBe(DIRECTORY_TTL_SECONDS); + expect(directory.payload.bindings).toEqual([ + { + bindingId: "611ffbbb-9f60-4601-ba39-4c241b900497", + endpointId: ENDPOINT_A, + clientNamespace: "irx", + deviceId: "77116c35-0000-4000-8000-000000000001", + instanceTag: "irx", + homeRelayUrl: RELAY_1, + updatedAt: "2026-08-26T00:00:00Z", + // listv2 overlay join: a hello without client info leaves the binding + // seeded and unrevoked. + status: "seeded", + revoked: false, + }, + ]); + + const passes = socket.frame("relay_passes") as { + rev: number; + payload: { endpointId: string; passes: Record[] }; + }; + expect(passes.rev).toBe(42); + expect(passes.payload.endpointId).toBe(ENDPOINT_A); + expect(passes.payload.passes.map((pass) => pass.relayUrl)).toEqual([RELAY_1, RELAY_2]); + expect(passes.payload.passes.every((pass) => pass.generation === 1)).toBe(true); + + expect(socket.frame("snapshot_complete")?.rev).toBe(42); + + // Discovery is account-scoped because the control-plane socket has no + // endpoint private key for a binding proof. Mint still carries the + // socket's app namespace and replicates the Swift client's request body. + const discovery = harness.discoveryCalls(); + expect(discovery).toHaveLength(1); + expect(discovery[0]?.init.headers.authorization).toBe("Bearer token-s1"); + expect(discovery[0]?.init.headers["x-cmux-app-namespace"]).toBe("legacy"); + const mint = harness.mintCalls(); + expect(mint).toHaveLength(1); + expect(mint[0]?.init.method).toBe("POST"); + expect(mint[0]?.init.body).toBe(JSON.stringify({ endpointId: ENDPOINT_A })); + expect(mint[0]?.init.headers.authorization).toBe("Bearer token-s1"); + }); + + it("skips the directory body on the haveRev fast path", async () => { + const harness = new Harness(); + // Seed the DO cache as if a previous snapshot had run. + const seeded = directoryPayloadFromDiscovery(discoveryResponse(42)); + harness.map.set(REV_KEY, 42); + harness.map.set(DIR_KEY, seeded?.payload); + + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: 42, wantPasses: false }); + + expect(socket.types()).toEqual(["hello_ack", "snapshot_complete"]); + expect(socket.frame("hello_ack")?.payload).toEqual({ + sessionId: "s1", + resumedFromRev: 42, + serverCapabilities: [...CONTROL_SERVER_CAPABILITIES], + }); + expect(socket.frame("snapshot_complete")?.rev).toBe(42); + expect(harness.discoveryCalls()).toHaveLength(0); // no upstream fetch at all + }); + + it("serves an error plus cached facts when the directory fetch fails with a cache", async () => { + const harness = new Harness(); + const seeded = directoryPayloadFromDiscovery(discoveryResponse(42)); + harness.map.set(REV_KEY, 42); + harness.map.set(DIR_KEY, seeded?.payload); + harness.routes.set("/api/devices/iroh", () => { + throw new Error("connection reset"); + }); + + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: 41, wantPasses: false }); + + expect(socket.types()).toEqual(["hello_ack", "error", "directory", "snapshot_complete"]); + expect(socket.frame("error")?.payload).toMatchObject({ + code: "directory_unavailable", + retryable: true, + }); + expect(socket.frame("directory")?.rev).toBe(42); // cached facts still served + expect(harness.discoveryCalls()).toHaveLength(2); // one immediate retry + }); + + it("marks the snapshot pending on fetch failure without a cache, then recovers on alarm", async () => { + const harness = new Harness(); + harness.routes.set("/api/devices/iroh", () => { + throw new Error("connection reset"); + }); + + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + + expect(socket.types()).toEqual(["hello_ack", "error"]); + expect(socket.getAttachment()?.snapshotPending).toBe(true); + expect(harness.alarms).toContain(T0 + SNAPSHOT_RETRY_DELAY_MS); + + // Upstream recovers; the alarm completes the snapshot. + harness.serveDiscovery(() => discoveryResponse(42)); + await harness.core.handleAlarm(); + expect(socket.types()).toEqual(["hello_ack", "error", "directory", "snapshot_complete", "ping"]); + expect(socket.frame("snapshot_complete")?.rev).toBe(42); + expect(socket.getAttachment()?.snapshotPending).toBe(false); + }); + + it("answers the application heartbeat without routing it as a durable fact", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + socket.clearFrames(); + + await harness.core.handleMessage(socket, JSON.stringify({ + v: 1, + type: "ping", + payload: { at: new Date(T0).toISOString() }, + })); + + expect(socket.types()).toEqual(["pong"]); + }); + + it("keeps a production-style control socket alive without a subscription deadline", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + const socket = await harness.connectLongLived("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + socket.clearFrames(); + + // A long-lived control attachment has no expiry sweep even after the + // short-lived deadlines used by the legacy test adapter would have passed. + harness.now += 2 * 60 * 60 * 1_000; + await harness.core.handleAlarm(); + + expect(socket.closes).toEqual([]); + expect(socket.types()).toContain("ping"); + }); + + it("ignores a duplicate hello (reconnect is the resync path)", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + const framesAfterFirst = socket.frames.length; + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + expect(socket.frames.length).toBe(framesAfterFirst); + expect(harness.discoveryCalls()).toHaveLength(1); + }); +}); + +describe("mint_request proxying", () => { + async function snapshotted(harness: Harness, sessionId: string): Promise { + const socket = await harness.connect(sessionId); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: 42, wantPasses: false }); + socket.clearFrames(); + return socket; + } + + function seed(harness: Harness): void { + const seeded = directoryPayloadFromDiscovery(discoveryResponse(42)); + harness.map.set(REV_KEY, 42); + harness.map.set(DIR_KEY, seeded?.payload); + } + + it("retries exactly once on connection-level failure, then succeeds", async () => { + const harness = new Harness(); + seed(harness); + let attempts = 0; + harness.serveMint(() => { + attempts += 1; + if (attempts === 1) throw new Error("connection reset"); + return { status: 200, json: mintResponse(ENDPOINT_A) }; + }); + + const socket = await snapshotted(harness, "s1"); + await harness.send(socket, { + v: 1, + type: "mint_request", + payload: { endpointId: ENDPOINT_A }, + }); + + expect(attempts).toBe(2); + expect(socket.types()).toEqual(["relay_passes"]); + expect((socket.frame("relay_passes") as { rev: number }).rev).toBe(42); + }); + + it("reports a retryable error after the single retry also fails, without crashing the socket", async () => { + const harness = new Harness(); + seed(harness); + let attempts = 0; + harness.serveMint(() => { + attempts += 1; + throw new Error("connection reset"); + }); + + const socket = await snapshotted(harness, "s1"); + await harness.send(socket, { + v: 1, + type: "mint_request", + payload: { endpointId: ENDPOINT_A }, + }); + + expect(attempts).toBe(2); // once + ONE immediate retry, never more + expect(socket.types()).toEqual(["error"]); + expect(socket.frame("error")?.payload).toEqual({ + code: "mint_upstream_unavailable", + message: "relay token mint failed upstream", + retryable: true, + }); + expect(socket.closes).toHaveLength(0); + + // The socket keeps working: upstream heals, the next mint succeeds. + harness.serveMint(() => ({ status: 200, json: mintResponse(ENDPOINT_A) })); + await harness.send(socket, { + v: 1, + type: "mint_request", + payload: { endpointId: ENDPOINT_A }, + }); + expect(socket.types()).toEqual(["error", "relay_passes"]); + }); + + it("does not retry HTTP-level failures and maps status classes to retryability", async () => { + const harness = new Harness(); + seed(harness); + let attempts = 0; + harness.serveMint(() => { + attempts += 1; + return { status: 503, json: { error: "iroh_service_unavailable" } }; + }); + const socket = await snapshotted(harness, "s1"); + await harness.send(socket, { v: 1, type: "mint_request", payload: { endpointId: ENDPOINT_A } }); + expect(attempts).toBe(1); // an HTTP response is never retried + expect(socket.frame("error")?.payload).toMatchObject({ + code: "mint_upstream_unavailable", + retryable: true, + }); + + socket.clearFrames(); + harness.serveMint(() => ({ status: 403, json: { error: "invalid_binding_request_proof" } })); + await harness.send(socket, { v: 1, type: "mint_request", payload: { endpointId: ENDPOINT_A } }); + expect(socket.frame("error")?.payload).toMatchObject({ code: "mint_rejected", retryable: false }); + }); + + it("uses each socket's own bearer, never another socket's", async () => { + const harness = new Harness(); + seed(harness); + harness.serveMint(() => ({ status: 200, json: mintResponse(ENDPOINT_B) })); + + await snapshotted(harness, "s1"); + const other = await snapshotted(harness, "s2"); + await harness.send(other, { v: 1, type: "mint_request", payload: { endpointId: ENDPOINT_B } }); + + const mint = harness.mintCalls(); + expect(mint).toHaveLength(1); + expect(mint[0]?.init.headers.authorization).toBe("Bearer token-s2"); + }); + + it("bumps the per-endpoint generation on every successful mint", async () => { + const harness = new Harness(); + seed(harness); + harness.serveMint(() => ({ status: 200, json: mintResponse(ENDPOINT_A) })); + const socket = await snapshotted(harness, "s1"); + await harness.send(socket, { v: 1, type: "mint_request", payload: { endpointId: ENDPOINT_A } }); + await harness.send(socket, { v: 1, type: "mint_request", payload: { endpointId: ENDPOINT_A } }); + const generations = socket.frames + .filter((frame) => frame.type === "relay_passes") + .map((frame) => ((frame.payload as { passes: { generation: number }[] }).passes[0]?.generation)); + expect(generations).toEqual([1, 2]); + }); +}); + +describe("publish_hint announcements", () => { + it("fans out hint_update to the account's other snapshotted sockets and schedules the confirm pass", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + + const mac = await harness.connect("mac"); + await harness.hello(mac, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + const phone = await harness.connect("phone"); + await harness.hello(phone, { endpointId: ENDPOINT_B, haveRev: 42, wantPasses: false }); + const preHello = await harness.connect("quiet"); // never sent hello + mac.clearFrames(); + phone.clearFrames(); + harness.alarms = []; + + await harness.send(mac, { + v: 1, + type: "publish_hint", + payload: { endpointId: ENDPOINT_A, homeRelayUrl: RELAY_2 }, + }); + + // The announcer hears nothing back; the peer gets the hint immediately. + expect(mac.frames).toHaveLength(0); + expect(phone.types()).toEqual(["hint_update"]); + const update = phone.frame("hint_update") as { rev: number; payload: Record }; + expect(update.rev).toBe(42); + expect(update.payload.endpointId).toBe(ENDPOINT_A); + expect(update.payload.homeRelayUrl).toBe(RELAY_2); + expect(typeof update.payload.updatedAt).toBe("string"); + // A socket that never helloed has no snapshot baseline and is skipped. + expect(preHello.frames).toHaveLength(0); + // Confirm-against-broker-truth pass is scheduled a few seconds out. + expect(harness.alarms).toEqual([T0 + HINT_CONFIRM_DELAY_MS]); + // Phase A never writes hints upstream; the Mac's own signed registration does. + expect(harness.calls.filter((call) => call.init.method !== "GET")).toHaveLength(0); + }); + + it("rejects an implausible relay URL with a non-retryable error", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + const mac = await harness.connect("mac"); + await harness.hello(mac, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + mac.clearFrames(); + + await harness.send(mac, { + v: 1, + type: "publish_hint", + payload: { endpointId: ENDPOINT_A, homeRelayUrl: "not-a-url" }, + }); + expect(mac.frame("error")?.payload).toMatchObject({ code: "invalid_hint", retryable: false }); + }); +}); + +describe("alarm-driven refresh", () => { + async function snapshottedPair(harness: Harness): Promise<[FakeSocket, FakeSocket]> { + harness.serveDiscovery(() => discoveryResponse(42)); + const first = await harness.connect("s1"); + await harness.hello(first, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + const second = await harness.connect("s2"); + await harness.hello(second, { endpointId: ENDPOINT_B, haveRev: 42, wantPasses: false }); + first.clearFrames(); + second.clearFrames(); + harness.calls = []; + harness.alarms = []; + return [first, second]; + } + + it("broadcasts hint_update to every socket when only a home relay moved", async () => { + const harness = new Harness(); + const [first, second] = await snapshottedPair(harness); + + harness.serveDiscovery(() => discoveryResponse(43, { homeRelayUrl: RELAY_2 })); + await harness.core.handleAlarm(); + + for (const socket of [first, second]) { + expect(socket.types()).toEqual(["hint_update", "ping"]); + const update = socket.frame("hint_update") as { rev: number; payload: Record }; + expect(update.rev).toBe(43); + expect(update.payload.homeRelayUrl).toBe(RELAY_2); + } + expect(harness.map.get(REV_KEY)).toBe(43); + // Cadence continues while sockets are connected. + expect(harness.alarms).toContain(T0 + CONTROL_REFRESH_INTERVAL_MS); + }); + + it("broadcasts the full directory when the binding set changed", async () => { + const harness = new Harness(); + const [first, second] = await snapshottedPair(harness); + + harness.serveDiscovery(() => discoveryResponse(44, { extraBinding: true })); + await harness.core.handleAlarm(); + + for (const socket of [first, second]) { + expect(socket.types()).toEqual(["directory", "ping"]); + const directory = socket.frame("directory") as { rev: number; payload: { bindings: unknown[] } }; + expect(directory.rev).toBe(44); + expect(directory.payload.bindings).toHaveLength(2); + } + }); + + it("broadcasts nothing when the revision did not move", async () => { + const harness = new Harness(); + const [first, second] = await snapshottedPair(harness); + await harness.core.handleAlarm(); + expect(first.types()).toEqual(["ping"]); + expect(second.types()).toEqual(["ping"]); + expect(harness.discoveryCalls()).toHaveLength(1); // it did re-fetch + }); + + it("falls back to a storage revision counter when upstream omits revision", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => { + const response = discoveryResponse(1) as Record; + delete response.revision; + return response; + }); + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + expect(socket.frame("directory")?.rev).toBe(1); // 0 -> content changed -> 1 + socket.clearFrames(); + + harness.serveDiscovery(() => { + const response = discoveryResponse(1, { homeRelayUrl: RELAY_2 }) as Record; + delete response.revision; + return response; + }); + await harness.core.handleAlarm(); + expect((socket.frame("hint_update") as { rev: number }).rev).toBe(2); + }); + + it("closes expired sockets, deletes their bearers, and stops the cadence when idle", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + expect(harness.map.has(`${BEARER_PREFIX}s1`)).toBe(true); + + harness.now = T0 + 16 * 60_000; // past the 15-minute deadline + harness.alarms = []; + await harness.core.handleAlarm(); + + expect(socket.closes).toHaveLength(1); + expect(harness.map.has(`${BEARER_PREFIX}s1`)).toBe(false); + expect(harness.alarms).toHaveLength(0); // idle account: no reschedule + }); +}); diff --git a/workers/presence/tsconfig.test.json b/workers/presence/tsconfig.test.json index dc19133affe7..3523d2ca29c3 100644 --- a/workers/presence/tsconfig.test.json +++ b/workers/presence/tsconfig.test.json @@ -10,6 +10,8 @@ "src/validate.ts", "src/sync.ts", "src/syncDevices.ts", - "src/syncStorage.ts" + "src/syncStorage.ts", + "src/controlPlane.ts", + "src/controlPlaneProof.ts" ] } diff --git a/workers/presence/wrangler.dev.toml b/workers/presence/wrangler.dev.toml index 53a1aded8162..e72955eacdfc 100644 --- a/workers/presence/wrangler.dev.toml +++ b/workers/presence/wrangler.dev.toml @@ -10,10 +10,26 @@ workers_dev = true [observability] enabled = true +# Dev builds authenticate against the shared staging web deployment (the same +# origin AuthEnvironment.irohBrokerBaseURL resolves for Debug builds), so the +# control-plane DO must proxy discovery/mint upstream calls there too. +[vars] +CMUX_WEB_BASE_URL = "https://cmux-staging.vercel.app" + [[durable_objects.bindings]] name = "TEAM_PRESENCE" class_name = "TeamPresence" +# Account control plane (see wrangler.toml). Dev deploys usually also want +# CMUX_WEB_BASE_URL pointed at a dev/preview web deployment. +[[durable_objects.bindings]] +name = "ACCOUNT_CONTROL_PLANE" +class_name = "AccountControlPlane" + [[migrations]] tag = "v1" new_sqlite_classes = ["TeamPresence"] + +[[migrations]] +tag = "v2" +new_sqlite_classes = ["AccountControlPlane"] diff --git a/workers/presence/wrangler.toml b/workers/presence/wrangler.toml index c3c3733bf341..b43bccabc926 100644 --- a/workers/presence/wrangler.toml +++ b/workers/presence/wrangler.toml @@ -30,10 +30,23 @@ enabled = true name = "TEAM_PRESENCE" class_name = "TeamPresence" +# Account control plane: one DO per verified Stack user id serving the +# /v1/control/socket WebSocket (directory + hint + relay-pass facts, proxied +# from the Vercel broker with the connecting client's own bearer token). The +# Vercel base URL is the optional plain var CMUX_WEB_BASE_URL (defaults to +# https://cmux.com in code, same pattern as STACK_API_URL). +[[durable_objects.bindings]] +name = "ACCOUNT_CONTROL_PLANE" +class_name = "AccountControlPlane" + [[migrations]] tag = "v1" new_sqlite_classes = ["TeamPresence"] +[[migrations]] +tag = "v2" +new_sqlite_classes = ["AccountControlPlane"] + # Production custom domain on the cmux.dev zone (same Cloudflare account). # `custom_domain = true` provisions the DNS record + TLS cert with the deploy; # the workers.dev URL stays as the dev/staging entrypoint.