diff --git a/.github/workflows/iroh-relay-minter.yml b/.github/workflows/iroh-relay-minter.yml deleted file mode 100644 index 8a8f07e850ef..000000000000 --- a/.github/workflows/iroh-relay-minter.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Iroh relay minter - -on: - # CI pause: preserve explicit validation without automatic PR/main runs. - workflow_dispatch: - -concurrency: - group: iroh-relay-minter-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - test: - runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} - timeout-minutes: 40 - defaults: - run: - working-directory: services/iroh-relay-minter - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Install pinned Rust toolchain - run: rustup toolchain install 1.91.0 --profile minimal --component clippy --component rustfmt - - - name: Check formatting - run: cargo fmt --check - - - name: Lint - run: cargo clippy --all-targets --locked -- -D warnings - - - name: Test - run: cargo test --locked - - - name: Build production function - run: cargo build --release --locked diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift index 784a545bb1f1..4de4ddea1fa7 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityEngine.swift @@ -26,6 +26,9 @@ public actor CmxConnectivityEngine { private let installRouteSnapshot: RouteSnapshotInstaller? private let diagnosticLog: DiagnosticLog? private let clock: any CmxIrohRelayClock + /// Deadline for each dial phase (public paths, private fallback, and the + /// admission barrier) of every peer session this engine creates. + private let dialPhaseTimeout: Duration private var desiredActive = false private var lifecycleRevision: UInt64 = 0 private var endpointGeneration: UInt64? @@ -59,7 +62,8 @@ public actor CmxConnectivityEngine { authority: (any CmxConnectivityAuthorityServing)? = nil, installRouteSnapshot: RouteSnapshotInstaller? = nil, diagnosticLog: DiagnosticLog? = nil, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock() + clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), + dialPhaseTimeout: Duration = .seconds(5) ) { precondition((authority == nil) == (installRouteSnapshot == nil)) supervisor = CmxIrohEndpointSupervisor( @@ -72,6 +76,7 @@ public actor CmxConnectivityEngine { self.installRouteSnapshot = installRouteSnapshot self.diagnosticLog = diagnosticLog self.clock = clock + self.dialPhaseTimeout = dialPhaseTimeout } /// Creates a stopped endpoint-only engine for a host acceptor. @@ -90,6 +95,7 @@ public actor CmxConnectivityEngine { installRouteSnapshot = nil diagnosticLog = nil clock = CmxIrohSystemRelayClock() + dialPhaseTimeout = .seconds(5) } init( @@ -99,7 +105,8 @@ public actor CmxConnectivityEngine { authority: (any CmxConnectivityAuthorityServing)? = nil, installRouteSnapshot: RouteSnapshotInstaller? = nil, diagnosticLog: DiagnosticLog? = nil, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock() + clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), + dialPhaseTimeout: Duration = .seconds(5) ) { precondition((authority == nil) == (installRouteSnapshot == nil)) self.supervisor = supervisor @@ -109,6 +116,7 @@ public actor CmxConnectivityEngine { self.installRouteSnapshot = installRouteSnapshot self.diagnosticLog = diagnosticLog self.clock = clock + self.dialPhaseTimeout = dialPhaseTimeout } /// Returns the current immutable UI-safe state. @@ -306,6 +314,11 @@ public actor CmxConnectivityEngine { await supervisor.hasConfiguredRelay() } + /// Returns whether the active endpoint generation reports a usable home relay. + public func hasUsableHomeRelay() async -> Bool { + await supervisor.hasUsableHomeRelay() + } + /// Waits for the active endpoint generation to report relay readiness. public func waitForUsableHomeRelay( timeout: Duration = .seconds(15) @@ -332,17 +345,6 @@ public actor CmxConnectivityEngine { ) } - /// Replaces active managed relay credentials without changing identity. - public func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity - ) async throws { - try await supervisor.replaceRelays( - relays, - expectedIdentity: expectedIdentity - ) - } - /// Returns the selected live path after removing raw coordinates. public func selectedTransportPath( relayPolicy: CmxIrohEffectiveRelayPolicy? @@ -533,6 +535,7 @@ public actor CmxConnectivityEngine { let protocolConfiguration = protocolConfiguration let diagnosticLog = diagnosticLog let clock = clock + let dialPhaseTimeout = dialPhaseTimeout let peer = CmxConnectivityPeerSession( peerID: peerID, buildSession: { request in @@ -551,6 +554,7 @@ public actor CmxConnectivityEngine { basedOn: context ) }, + dialPhaseTimeout: dialPhaseTimeout, protocolConfiguration: protocolConfiguration, diagnostics: diagnosticLog ) @@ -914,5 +918,3 @@ public actor CmxConnectivityEngine { return lhs.deviceID < rhs.deviceID } } - -extension CmxConnectivityEngine: CmxIrohRelayEndpointControlling {} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift index 8a8e79439bdf..5ed19e74e752 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBackpressuredBroker.swift @@ -74,18 +74,6 @@ public struct CmxIrohBackpressuredClientBroker: } } - public func issueRelayToken( - bindingID: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - try await gate.perform(accountID: accountID, operation: .relayCredential) { - try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointID - ) - } - } - public func revoke(bindingID: String) async throws { try await gate.perform(accountID: accountID, operation: .revocation) { try await broker.revoke(bindingID: bindingID) @@ -167,18 +155,6 @@ public struct CmxIrohBackpressuredHostBroker: } } - public func issueRelayToken( - bindingID: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - try await gate.perform(accountID: accountID, operation: .relayCredential) { - try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointID - ) - } - } - public func revoke(bindingID: String) async throws { try await gate.perform(accountID: accountID, operation: .revocation) { try await broker.revoke(bindingID: bindingID) @@ -209,11 +185,10 @@ public struct CmxIrohBackpressuredRelayPolicyBroker: CmxIrohRelayPolicyServing, self.accountID = accountID } - public func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { + /// Fetches the signed relay policy through the shared account gate. + public func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { try await gate.perform(accountID: accountID, operation: .relayCredential) { - try await broker.issueRelayBootstrap(endpointID: endpointID) + try await broker.fetchRelayPolicy() } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift index ea30aabd86df..b42293bd15bc 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift @@ -1,7 +1,9 @@ import CryptoKit public import Foundation -/// Persists one active account's broker binding and relay capability. +/// Persists one active account's broker binding. The Keychain-backed secure +/// store survives only to delete legacy relay-credential records; no relay +/// credentials exist any more (relay admission is the relay's allow hook). public actor CmxIrohBrokerCredentialRepository { private static let activeScopeKey = "cmux.iroh.broker-credentials.scope.v1" private static let bindingKey = "cmux.iroh.broker-credentials.binding.v1" @@ -53,7 +55,8 @@ public actor CmxIrohBrokerCredentialRepository { ) } - /// Saves an exact broker binding, invalidating relay credentials if it changed. + /// Saves an exact broker binding, deleting any legacy token-era secure + /// record if the binding changed. /// /// - Parameters: /// - binding: The binding tuple returned by registration or discovery. @@ -82,123 +85,6 @@ public actor CmxIrohBrokerCredentialRepository { installState.set(String(decoding: encoded, as: UTF8.self), forKey: Self.bindingKey) } - /// Loads a fresh relay credential for one exact binding and managed fleet. - /// - /// Stale, corrupt, wrong-binding, and wrong-fleet capabilities are deleted - /// and returned as a cache miss. - /// - /// - Parameters: - /// - accountID: The authenticated account identifier. - /// - binding: The exact active binding tuple. - /// - expectedRelayFleet: The complete configured managed relay fleet. - /// - now: The validation time. - /// - Returns: A validated relay credential, or `nil` when a new mint is required. - /// - Throws: A scope-validation or secure-storage error. - public func loadRelayCredential( - accountID: String, - binding: CmxIrohBrokerBindingMetadata, - expectedRelayFleet: Set, - now: Date - ) async throws -> CmxIrohRelayTokenResponse? { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) - guard try await loadBinding( - scope: scope, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) == binding else { - try await deleteSecureRecord(account: scope, epoch: epoch) - return nil - } - guard let data = try await readSecureRecord(account: scope, epoch: epoch), - let stored = try? JSONDecoder().decode( - CmxIrohStoredRelayCredential.self, - from: data - ), - stored.version == CmxIrohStoredRelayCredential.currentVersion, - stored.binding == binding, - hasExactFleet(stored.response.relayFleet, expected: expectedRelayFleet), - (try? stored.response.relayConfigurations(now: now))?.count - == expectedRelayFleet.count else { - try await deleteSecureRecord(account: scope, epoch: epoch) - return nil - } - try requireCurrent(epoch) - return stored.response - } - - /// Saves a fresh relay credential for one exact binding and managed fleet. - /// - /// - Parameters: - /// - response: The relay token response returned by the trust broker. - /// - accountID: The authenticated account identifier. - /// - binding: The exact active binding tuple. - /// - expectedRelayFleet: The complete configured managed relay fleet. - /// - now: The validation time. - /// - Throws: A validation, encoding, or secure-storage error. - public func saveRelayCredential( - _ response: CmxIrohRelayTokenResponse, - accountID: String, - binding: CmxIrohBrokerBindingMetadata, - expectedRelayFleet: Set, - now: Date - ) async throws { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) - guard let storedBinding = try await loadBinding( - scope: scope, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) else { - throw CmxIrohBrokerCredentialRepositoryError.bindingNotStored - } - guard storedBinding == binding else { - try await deleteSecureRecord(account: scope, epoch: epoch) - throw CmxIrohBrokerCredentialRepositoryError.bindingMismatch - } - guard hasExactFleet(response.relayFleet, expected: expectedRelayFleet) else { - throw CmxIrohBrokerCredentialRepositoryError.relayFleetMismatch - } - guard (try? response.relayConfigurations(now: now))?.count - == expectedRelayFleet.count else { - throw CmxIrohBrokerCredentialRepositoryError.invalidRelayCredential - } - let record = CmxIrohStoredRelayCredential(binding: binding, response: response) - try await writeSecureRecord( - JSONEncoder().encode(record), - account: scope, - accessibility: .afterFirstUnlockThisDeviceOnly, - epoch: epoch - ) - } - - /// Removes a relay credential while preserving its broker binding. - /// - /// - Parameters: - /// - accountID: The authenticated account identifier. - /// - appInstanceID: The installation's lowercase app-instance UUID. - /// - Throws: A scope-validation or secure-storage error. - public func deleteRelayCredential( - accountID: String, - appInstanceID: String - ) async throws { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: appInstanceID, - epoch: epoch - ) - try await deleteSecureRecord(account: scope, epoch: epoch) - } - /// Removes a broker binding and every capability scoped to it. /// /// - Parameters: @@ -347,12 +233,6 @@ public actor CmxIrohBrokerCredentialRepository { } } - private func hasExactFleet(_ fleet: [String], expected: Set) -> Bool { - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(expected.count) - && fleet.count == expected.count - && Set(fleet) == expected - } - private static func scope(accountID: String, appInstanceID: String) -> String { let transcript = Data( "cmux/iroh/broker-credential-scope/v1\0\(accountID)\0\(appInstanceID)".utf8 diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift index df3d7332e2bd..8ec13d9ca57a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift @@ -352,9 +352,9 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable { } } -/// Result of the registration route's best-effort initial relay mint. +/// Wire-compatibility status field of the registration response. Clients hold +/// no relay credentials; relay admission is the relay's server-side allow hook. public enum CmxIrohRegistrationRelay: Decodable, Equatable, Sendable { - case issued(CmxIrohRelayTokenResponse) case unavailable case notRequested @@ -364,9 +364,7 @@ public enum CmxIrohRegistrationRelay: Decodable, Equatable, Sendable { let status = try decoder.container(keyedBy: CodingKeys.self) .decode(String.self, forKey: .status) switch status { - case "issued": - self = try .issued(CmxIrohRelayTokenResponse(from: decoder)) - case "unavailable": + case "unavailable", "issued": self = .unavailable case "not_requested": self = .notRequested diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift index 0d3719a5c0d2..910d635f81fc 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift @@ -1,6 +1,6 @@ /// Trust-broker operations required by an iOS Iroh client runtime. public protocol CmxIrohClientBrokerServing: CmxIrohRegistryServing, - CmxIrohRelayTokenServing, CmxIrohBindingRevoking + CmxIrohBindingRevoking { /// Checks a caller-owned broker floor without performing network work. func preflight(operation: CmxIrohBrokerOperation) async throws diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift index 9f3068f099e0..3e472a6b0d06 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift @@ -72,8 +72,6 @@ extension CmxIrohClientRuntime { registrationRefreshEnabled = false supervisorEventTask?.cancel() supervisorEventTask = nil - await relayCoordinator?.deactivate() - relayCoordinator = nil await contextRouter.clear() authoritativeDiscovery = nil if !preserveBinding { @@ -101,18 +99,6 @@ extension CmxIrohClientRuntime { } } - static func cachedRelayConfigurations( - configuration: CmxIrohClientRuntimeConfiguration, - now: Date - ) -> [CmxIrohRelayConfiguration] { - guard let cached = configuration.cachedRelayCredential, - cached.relayFleet.count == configuration.managedRelayURLs.count, - Set(cached.relayFleet) == configuration.managedRelayURLs else { - return [] - } - return (try? cached.relayConfigurations(now: now)) ?? [] - } - static func isConnectivity(_ error: any Error) -> Bool { (error as? CmxIrohTrustBrokerClientError) == .connectivity } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift index 4872842ad2d4..a08fe5a41b60 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift @@ -354,8 +354,7 @@ extension CmxIrohClientRuntime { func install( policy: ResolvedPolicy, - revision: UInt64, - startRelays: Bool + revision: UInt64 ) async throws { try requireCurrent(revision) let offlinePolicy = try policy.offlineExpectation.map { expectation in @@ -399,47 +398,5 @@ extension CmxIrohClientRuntime { } await contextRouter.install(provider) localBinding = policy.binding - - guard endpointRelayProfile.source == .managed, - !endpointRelayProfile.allowedRelayURLs.isEmpty else { - await relayCoordinator?.deactivate() - relayCoordinator = nil - return - } - - let coordinator: CmxIrohRelayCredentialCoordinator - if let relayCoordinator { - coordinator = relayCoordinator - } else { - coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, - broker: broker, - managedRelayURLs: managedRelayURLs, - selectedRelayURLs: endpointRelayProfile.allowedRelayURLs, - retrySchedule: .foregroundClient, - automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, policy.binding) - } - ) - relayCoordinator = coordinator - } - - let bootstrap = startRelays ? configuration.cachedRelayCredential : nil - if startRelays || bootstrap != nil { - let requiresRelayReadiness = !protocolConfiguration - .allowsNATTraversalAfterAdmission - do { - try await coordinator.activate( - bindingID: policy.binding.bindingID, - endpointIdentity: policy.binding.endpointID, - bootstrap: bootstrap, - waitForInitialCredential: requiresRelayReadiness - ) - } catch { - if requiresRelayReadiness { throw error } - // Registration remains authoritative; direct paths remain usable. - } - } } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift index 9510b095d8e2..f49993ebe484 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift @@ -98,7 +98,7 @@ extension CmxIrohClientRuntime { guard policy.binding.bindingID == previousBinding.bindingID else { throw CmxIrohClientRuntimeError.invalidLocalBinding } - try await install(policy: policy, revision: revision, startRelays: false) + try await install(policy: policy, revision: revision) try requireCurrent(revision) currentSnapshot = CmxIrohClientRuntimeSnapshot( state: .active, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift index ef1382f57fd0..e562b4aac031 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift @@ -8,8 +8,7 @@ extension CmxIrohClientRuntime { } ?? managedRelayURLs try await replaceRelayProfile( policy.endpointRelayProfile, - managedRelayURLs: verifiedManagedURLs, - relayBootstrap: policy.relayBootstrap + managedRelayURLs: verifiedManagedURLs ) } @@ -19,16 +18,20 @@ extension CmxIrohClientRuntime { ) async throws { try await replaceRelayProfile( profile, - managedRelayURLs: managedRelayURLs, - relayBootstrap: nil + managedRelayURLs: managedRelayURLs ) } private func replaceRelayProfile( _ profile: CmxIrohEndpointRelayProfile, - managedRelayURLs replacementManagedURLs: Set, - relayBootstrap: CmxIrohRelayTokenResponse? + managedRelayURLs replacementManagedURLs: Set ) async throws { + // A debug-only forced relay pins every profile installation, so a + // broker policy refresh cannot displace the test relay mid-run. + var profile = profile + if let debugOverride = CmxIrohDebugRelayOverride.activeProfile() { + profile = debugOverride + } guard lifecyclePhase == .active, let binding = localBinding else { throw CmxIrohClientRuntimeError.inactive } @@ -41,48 +44,10 @@ extension CmxIrohClientRuntime { } let revision = lifecycleRevision - await relayCoordinator?.deactivate() - relayCoordinator = nil - if profile.source == .managed, !profile.allowedRelayURLs.isEmpty { - let refreshSchedule = CmxIrohRelayRefreshSchedule( - role: .client, - endpointIdentity: binding.endpointID - ) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, - broker: broker, - managedRelayURLs: replacementManagedURLs, - selectedRelayURLs: profile.allowedRelayURLs, - jitter: { now, refreshAfter in - refreshSchedule.deadline(now: now, refreshAfter: refreshAfter) - }, - retrySchedule: .foregroundClient, - automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, binding) - } - ) - relayCoordinator = coordinator - do { - try await coordinator.activateManagedPolicy( - bindingID: binding.bindingID, - endpointIdentity: binding.endpointID, - profile: profile, - bootstrap: relayBootstrap - ) - } catch { - await coordinator.deactivate() - if relayCoordinator === coordinator { - relayCoordinator = nil - } - throw error - } - } else { - try await connectivityEngine.replaceRelayProfile( - profile, - expectedIdentity: binding.endpointID - ) - } + try await connectivityEngine.replaceRelayProfile( + profile, + expectedIdentity: binding.endpointID + ) try requireCurrent(revision) managedRelayURLs = replacementManagedURLs diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift index 9c3818e05d2b..79641a1f3349 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift @@ -20,12 +20,6 @@ public actor CmxIrohClientRuntime { public typealias CustomPrivateFallbackProvider = CmxIrohRegistryContextProvider.CustomPrivateFallbackProvider - /// Runs after a relay credential is installed on the exact active binding. - public typealias RelayCredentialHandler = @Sendable ( - _ response: CmxIrohRelayTokenResponse, - _ binding: CmxIrohBrokerBinding - ) async -> Void - /// Removes account-local identity, binding, relay, and route cache state. public typealias LocalDeactivationHandler = @Sendable () async -> Void @@ -74,6 +68,14 @@ public actor CmxIrohClientRuntime { let broker: any CmxIrohClientBrokerServing let configuration: CmxIrohClientRuntimeConfiguration var endpointRelayProfile: CmxIrohEndpointRelayProfile + /// Whether this runtime binds its endpoint with the managed relays + /// withheld and installs them only after ``start()`` has an acknowledged + /// broker registration. True exactly when no cached binding proves the + /// broker has already seen this endpoint: a fresh endpoint that dials an + /// admission-gated relay before its registration lands is denied by the + /// relay's allow hook, and that deny is negatively cached, so one lost + /// race costs the whole activation. + let withholdsManagedRelaysUntilRegistered: Bool var managedRelayURLs: Set let pendingRevocations: CmxIrohPendingRevocationOutbox let protocolConfiguration: CmxIrohProtocolConfiguration @@ -83,17 +85,14 @@ public actor CmxIrohClientRuntime { let customPrivateFallback: CustomPrivateFallbackProvider? let diagnosticLog: DiagnosticLog? let now: @Sendable () -> Date - let automaticRelayCredentialRefreshEnabled: Bool let handleBinding: BindingHandler let handleCachedBindings: CachedBindingsHandler - let handleRelayCredential: RelayCredentialHandler let handleLocalDeactivation: LocalDeactivationHandler let handlePolicyInvalidation: PolicyInvalidationHandler var lifecycleRevision: UInt64 = 0 var lifecyclePhase = LifecyclePhase.inactive var signOutOperation: Task? - var relayCoordinator: CmxIrohRelayCredentialCoordinator? var supervisorEventTask: Task? var registrationRefreshTask: Task? var registrationRefreshTaskID: UUID? @@ -130,7 +129,6 @@ public actor CmxIrohClientRuntime { /// private-network profiles. An empty profile set disables explicit hints. /// - now: Wall-clock injection for route and relay validation. /// - handleBinding: Persists the exact verified binding and discovery state. - /// - handleRelayCredential: Persists an installed relay credential. /// - handleLocalDeactivation: Wipes account-local Iroh caches during sign-out. /// - handlePolicyInvalidation: Clears persisted broker routes after a terminal refresh. /// - Throws: An endpoint configuration error for an invalid cached relay set. @@ -148,20 +146,28 @@ public actor CmxIrohClientRuntime { lanFallback: LANFallbackProvider? = nil, customPrivateFallback: CustomPrivateFallbackProvider? = nil, now: @escaping @Sendable () -> Date = { Date() }, - automaticRelayCredentialRefreshEnabled: Bool = true, handleBinding: @escaping BindingHandler = { _, _ in true }, handleCachedBindings: @escaping CachedBindingsHandler = { _, _ in }, - handleRelayCredential: @escaping RelayCredentialHandler = { _, _ in }, handleLocalDeactivation: @escaping LocalDeactivationHandler = {}, handlePolicyInvalidation: @escaping PolicyInvalidationHandler = {} ) throws { - let endpointRelayProfile = try configuration.resolvedEndpointRelayProfile( - now: now() - ) + let endpointRelayProfile = try configuration.resolvedEndpointRelayProfile() + // A cached binding proves the broker has acknowledged this endpoint, + // so its relay dials pass the allow hook immediately. Without one the + // endpoint binds relay-less and `start()` installs the managed relays + // only after registration is acknowledged, ordering the first relay + // dial after broker admission. Custom relays are user-operated and + // not admission-gated by the cmux broker, so they stay installed at + // bind (a broker outage must not disable them). + let withholdsManagedRelaysUntilRegistered = configuration.cachedBinding == nil + && endpointRelayProfile.source == .managed + && !endpointRelayProfile.activeRelays.isEmpty let endpointConfiguration = CmxIrohEndpointConfiguration( secretKey: configuration.identity.secretKey, alpns: [protocolConfiguration.alpn], - relayProfile: endpointRelayProfile + relayProfile: withholdsManagedRelaysUntilRegistered + ? .unavailableManagedSelection + : endpointRelayProfile ) let supervisor = CmxIrohEndpointSupervisor( factory: factory, @@ -172,7 +178,8 @@ public actor CmxIrohClientRuntime { supervisor: supervisor, contextProvider: contextRouter, protocolConfiguration: protocolConfiguration, - diagnosticLog: diagnosticLog + diagnosticLog: diagnosticLog, + dialPhaseTimeout: configuration.dialPhaseTimeout ) self.supervisor = supervisor self.connectivityEngine = connectivityEngine @@ -180,6 +187,7 @@ public actor CmxIrohClientRuntime { self.broker = broker self.configuration = configuration self.endpointRelayProfile = endpointRelayProfile + self.withholdsManagedRelaysUntilRegistered = withholdsManagedRelaysUntilRegistered managedRelayURLs = configuration.managedRelayURLs self.pendingRevocations = pendingRevocations self.protocolConfiguration = protocolConfiguration @@ -189,10 +197,8 @@ public actor CmxIrohClientRuntime { self.customPrivateFallback = customPrivateFallback self.diagnosticLog = diagnosticLog self.now = now - self.automaticRelayCredentialRefreshEnabled = automaticRelayCredentialRefreshEnabled self.handleBinding = handleBinding self.handleCachedBindings = handleCachedBindings - self.handleRelayCredential = handleRelayCredential self.handleLocalDeactivation = handleLocalDeactivation self.handlePolicyInvalidation = handlePolicyInvalidation transportFactory = CmxConnectivityByteTransportFactory( @@ -205,12 +211,6 @@ public actor CmxIrohClientRuntime { currentSnapshot } - /// Returns the non-secret hard expiry of the relay credential currently - /// installed on the live endpoint. - public func relayCredentialExpiresAt() async -> Date? { - await relayCoordinator?.credentialExpiresAt() - } - /// Monotonic count of online broker snapshots verified by this runtime. public func liveDiscoverySnapshotGeneration() -> UInt64 { liveDiscoveryGeneration @@ -372,8 +372,7 @@ public actor CmxIrohClientRuntime { cachedTargetBindings: [], cachedLANRendezvous: nil ), - revision: revision, - startRelays: false + revision: revision ) try requireCurrent(revision) let published = await handleBinding(discoveredBinding, discovery) @@ -484,14 +483,6 @@ public actor CmxIrohClientRuntime { ) do { - let startingRelayProfile = try endpointRelayProfile - .droppingExpiredManagedCredentials(at: now()) - if startingRelayProfile != endpointRelayProfile { - try await connectivityEngine.replaceRelayProfile( - startingRelayProfile - ) - endpointRelayProfile = startingRelayProfile - } await startSupervisorObservation(revision: revision) let cachedDiscoveryTask: Task? if configuration.cachedBinding != nil { @@ -518,7 +509,20 @@ public actor CmxIrohClientRuntime { brokerPreparationComplete: cachedDiscoveryTask != nil ) try requireCurrent(revision) - try await install(policy: policy, revision: revision, startRelays: true) + try await install(policy: policy, revision: revision) + if withholdsManagedRelaysUntilRegistered { + // The broker has now acknowledged this endpoint's binding + // (a fresh endpoint cannot reach here otherwise: cooldown + // and offline fallbacks both require prior broker proof). + // Installing the managed relays only now guarantees the + // first relay dial cannot race the relay's allow hook into + // a negatively cached deny. + try await connectivityEngine.replaceRelayProfile( + endpointRelayProfile, + expectedIdentity: endpointID + ) + try requireCurrent(revision) + } if !protocolConfiguration.allowsNATTraversalAfterAdmission { guard await connectivityEngine.hasConfiguredRelay() else { throw CmxIrohEndpointSupervisorError.relayReadinessTimedOut @@ -616,8 +620,6 @@ public actor CmxIrohClientRuntime { registrationRefreshEnabled = true _ = try await refreshLiveDiscoveryThrowing() try requireCurrent(revision) - try await relayCoordinator?.refreshIfNeeded() - try requireCurrent(revision) } catch { if lifecyclePhase == .active, lifecycleRevision == revision { registrationRefreshEnabled = true diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift index 893a1262e811..48e938678adf 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift @@ -34,9 +34,6 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { /// `nil` preserves automatic use of the complete managed fleet. public let endpointRelayProfile: CmxIrohEndpointRelayProfile? - /// A previously validated endpoint-scoped relay credential, when available. - public let cachedRelayCredential: CmxIrohRelayTokenResponse? - /// The exact locally persisted binding tuple from a prior verified discovery. /// /// When it still appears exactly once in an authenticated connectivity @@ -44,6 +41,12 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { /// flight. A signed registration refresh follows after activation. public let cachedBinding: CmxIrohBrokerBindingMetadata? + /// Deadline for each dial phase (public paths, private fallback, and the + /// admission barrier) of every peer dial this runtime performs. A phase + /// that never answers fails typed at this bound and hands control back to + /// recovery instead of holding the reconnect owner (cmux#9724). + public let dialPhaseTimeout: Duration + /// Creates an immutable iOS client lifecycle configuration. /// /// Broker-facing validation occurs when ``CmxIrohClientRuntime/start()`` @@ -58,8 +61,9 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { /// - capabilities: The advertised protocol capabilities. /// - managedRelayURLs: The exact managed relay fleet. /// - endpointRelayProfile: An optional local selection or custom override. - /// - cachedRelayCredential: A validated cached relay capability. /// - cachedBinding: A previously verified exact local binding tuple. + /// - dialPhaseTimeout: The per-phase dial deadline, injectable so tests + /// can shrink it. public init( accountID: String, deviceID: String, @@ -71,8 +75,8 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { capabilities: [String], managedRelayURLs: Set, endpointRelayProfile: CmxIrohEndpointRelayProfile? = nil, - cachedRelayCredential: CmxIrohRelayTokenResponse? = nil, - cachedBinding: CmxIrohBrokerBindingMetadata? = nil + cachedBinding: CmxIrohBrokerBindingMetadata? = nil, + dialPhaseTimeout: Duration = .seconds(5) ) { self.accountID = accountID self.deviceID = cmxCanonicalDeviceID(deviceID) @@ -84,7 +88,17 @@ public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { self.capabilities = capabilities self.managedRelayURLs = managedRelayURLs self.endpointRelayProfile = endpointRelayProfile - self.cachedRelayCredential = cachedRelayCredential self.cachedBinding = cachedBinding + self.dialPhaseTimeout = dialPhaseTimeout + } +} + +extension CmxIrohClientRuntimeConfiguration { + func resolvedEndpointRelayProfile( + debugOverride: CmxIrohEndpointRelayProfile? = CmxIrohDebugRelayOverride.activeProfile() + ) throws -> CmxIrohEndpointRelayProfile { + if let debugOverride { return debugOverride } + return try endpointRelayProfile + ?? CmxIrohEndpointRelayProfile(managedRelayURLs: managedRelayURLs) } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift index e812caa454e6..7c39cc31d281 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift @@ -431,56 +431,81 @@ public actor CmxIrohClientSession { do { try Task.checkCancellation() - guard await establishedConnection.remoteIdentity() == targetIdentity else { - throw CmxIrohClientSessionError.remoteIdentityMismatch - } - try await establishedConnection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 0, - maximumUnidirectionalStreamCount: 0 - ) - let stream = try await establishedConnection.openBidirectionalStream() - let header = try CmxIrohStreamHeader( - lane: .control, - credential: credential - ) - try await stream.sendStream.send(headerCodec.encode(header)) - let admission = try await readAdmissionFrame(from: stream.receiveStream) - switch admission.frame { - case .acceptedPendingNatTraversal, .acceptedRelayOnly: - if admission.frame == .acceptedPendingNatTraversal { - try Task.checkCancellation() - try await establishedConnection.authorizeNatTraversal() + // A half-ready peer can accept the QUIC connection and then never + // serve the admission frames. Bound the whole barrier like a dial + // phase so a silent peer hands control back to recovery instead + // of holding the redial owner open-endedly (cmux#9724). The + // barrier's stream I/O sits in FFI calls that ignore task + // cancellation, so the deadline aborts at the transport boundary: + // closing the connection is what actually ends a stalled read. + return try await boundedByDialPhase( + abortOnDeadline: { + await establishedConnection.close( + errorCode: 1, + reason: "admission_timeout" + ) } - try Task.checkCancellation() - try await stream.sendStream.send( - admissionCodec.encodeFrame(.clientReady) - ) - let confirmation = try await readAdmissionFrame( - from: stream.receiveStream, - initialBuffer: admission.trailingBytes + ) { [weak self] in + guard let self else { throw CancellationError() } + return try await self.performAdmissionBarrier( + on: establishedConnection ) - switch confirmation.frame { - case .serverReady: - break - case let .denied(code): - throw CmxIrohClientSessionError.admissionDenied(code: code) - case .acceptedPendingNatTraversal, .acceptedRelayOnly, .clientReady: - throw CmxIrohClientSessionError.invalidAdmissionFrame - } + } + } catch { + await establishedConnection.close(errorCode: 1, reason: "admission_failed") + throw error + } + } + + private func performAdmissionBarrier( + on establishedConnection: any CmxIrohConnection + ) async throws -> CmxIrohConnectedControl { + guard await establishedConnection.remoteIdentity() == targetIdentity else { + throw CmxIrohClientSessionError.remoteIdentityMismatch + } + try await establishedConnection.setIncomingStreamLimits( + maximumBidirectionalStreamCount: 0, + maximumUnidirectionalStreamCount: 0 + ) + let stream = try await establishedConnection.openBidirectionalStream() + let header = try CmxIrohStreamHeader( + lane: .control, + credential: credential + ) + try await stream.sendStream.send(headerCodec.encode(header)) + let admission = try await readAdmissionFrame(from: stream.receiveStream) + switch admission.frame { + case .acceptedPendingNatTraversal, .acceptedRelayOnly: + if admission.frame == .acceptedPendingNatTraversal { try Task.checkCancellation() - return CmxIrohConnectedControl( - connection: establishedConnection, - stream: stream, - initialReceiveBuffer: confirmation.trailingBytes - ) + try await establishedConnection.authorizeNatTraversal() + } + try Task.checkCancellation() + try await stream.sendStream.send( + admissionCodec.encodeFrame(.clientReady) + ) + let confirmation = try await readAdmissionFrame( + from: stream.receiveStream, + initialBuffer: admission.trailingBytes + ) + switch confirmation.frame { + case .serverReady: + break case let .denied(code): throw CmxIrohClientSessionError.admissionDenied(code: code) - case .clientReady, .serverReady: + case .acceptedPendingNatTraversal, .acceptedRelayOnly, .clientReady: throw CmxIrohClientSessionError.invalidAdmissionFrame } - } catch { - await establishedConnection.close(errorCode: 1, reason: "admission_failed") - throw error + try Task.checkCancellation() + return CmxIrohConnectedControl( + connection: establishedConnection, + stream: stream, + initialReceiveBuffer: confirmation.trailingBytes + ) + case let .denied(code): + throw CmxIrohClientSessionError.admissionDenied(code: code) + case .clientReady, .serverReady: + throw CmxIrohClientSessionError.invalidAdmissionFrame } } @@ -498,22 +523,43 @@ public actor CmxIrohClientSession { ) async throws -> any CmxIrohConnection { let endpoint = endpoint let alpn = protocolConfiguration.alpn + return try await boundedByDialPhase { + try await endpoint.connect(to: address, alpn: alpn) + } + } + + /// Races one dial-phase operation against the injected phase bound. + /// + /// The deadline must not depend on the operation observing cooperative + /// cancellation: the FFI driver suspends Swift callers on polled Rust + /// futures that `Task.cancel()` never resumes, and the task group still + /// awaits the losing child on scope exit. When the timer wins, + /// `abortOnDeadline` first terminates the operation at the transport + /// boundary (closing the QUIC connection fails every pending stream + /// call), so the phase reliably fails typed and the redial machinery + /// supersedes it instead of wedging behind it (cmux#8531, cmux#9724). + /// The endpoint dial phase passes no abort hook because the endpoint + /// already bridges cancellation across the FFI boundary through the + /// fork's cancellable `ConnectAttempt`. + private func boundedByDialPhase( + abortOnDeadline: (@Sendable () async -> Void)? = nil, + _ operation: @escaping @Sendable () async throws -> Value + ) async throws -> Value { let bound = dialPhaseTimeout - return try await withThrowingTaskGroup( - of: (any CmxIrohConnection)?.self - ) { group in + return try await withThrowingTaskGroup(of: Value?.self) { group in group.addTask { - try await endpoint.connect(to: address, alpn: alpn) + try await operation() } group.addTask { try await ContinuousClock().sleep(for: bound) return nil } defer { group.cancelAll() } - guard let first = try await group.next(), let connection = first else { + guard let first = try await group.next(), let value = first else { + await abortOnDeadline?() throw CmxIrohClientSessionError.dialTimedOut } - return connection + return value } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugBrokerBypassHeader.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugBrokerBypassHeader.swift new file mode 100644 index 000000000000..33e6dc4e4170 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugBrokerBypassHeader.swift @@ -0,0 +1,54 @@ +public import Foundation + +/// A debug-build-only deployment-protection bypass for tagged test builds. +/// +/// Vercel preview deployments of the broker sit behind deployment +/// protection, which the app's broker client cannot pass. When active, +/// every trust-broker request carries the platform's +/// `x-vercel-protection-bypass` header so a tagged test build can talk to +/// a protected preview directly. Release builds compile the bypass away +/// entirely, and the value never applies to relay traffic (relays carry +/// their own bypass in their configured URLs). +public enum CmxIrohDebugBrokerBypassHeader { + /// The environment variable consulted first, and the `UserDefaults` + /// key consulted second, mirroring ``CmxIrohDebugRelayOverride``. + public static let key = "CMUX_IROH_BROKER_PROTECTION_BYPASS" + + /// The deployment-platform header that carries the bypass value. + static let headerField = "x-vercel-protection-bypass" + + /// The process-wide bypass value, or nil when inactive. + static func activeValue() -> String? { + #if DEBUG + value(rawValue: rawValue()) + #else + nil + #endif + } + + #if DEBUG + /// Reads the raw bypass value, preferring the process environment. + static func rawValue( + environment: [String: String] = ProcessInfo.processInfo.environment, + defaults: UserDefaults = .standard + ) -> String? { + if let fromEnvironment = environment[key], !fromEnvironment.isEmpty { + return fromEnvironment + } + return defaults.string(forKey: key) + } + + /// Accepts only a bounded single-line token safe to place in a header. + static func value(rawValue: String?) -> String? { + guard let value = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + value.utf8.count <= 128, + value.utf8.allSatisfy({ byte in + byte > 0x20 && byte < 0x7F + }) else { + return nil + } + return value + } + #endif +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift new file mode 100644 index 000000000000..121ddbdd2879 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverride.swift @@ -0,0 +1,56 @@ +public import Foundation + +/// A debug-build-only forced relay for tagged test builds. +/// +/// When active, every host (Mac) and dial (iOS) endpoint generation uses +/// exactly one operator-supplied relay, replacing managed policy, cached +/// credentials, and account preference. The override is read at each +/// endpoint-profile installation, so a later broker policy refresh cannot +/// displace it. Release builds compile the override away entirely. +public enum CmxIrohDebugRelayOverride { + /// The environment variable consulted first, and the `UserDefaults` key + /// consulted second. A `-CMUX_IROH_RELAY_URL_OVERRIDE ` launch + /// argument populates the defaults key on iOS builds whose launch + /// environment cannot carry variables. + public static let key = "CMUX_IROH_RELAY_URL_OVERRIDE" + + /// The process-wide override profile, or nil when inactive. + static func activeProfile() -> CmxIrohEndpointRelayProfile? { + #if DEBUG + profile(rawValue: rawValue()) + #else + nil + #endif + } + + #if DEBUG + /// Reads the raw override value, preferring the process environment. + static func rawValue( + environment: [String: String] = ProcessInfo.processInfo.environment, + defaults: UserDefaults = .standard + ) -> String? { + if let fromEnvironment = environment[key], !fromEnvironment.isEmpty { + return fromEnvironment + } + return defaults.string(forKey: key) + } + + /// Builds a strict single-relay custom profile, or nil for unusable input. + /// + /// The value must be a canonical HTTPS origin; a missing trailing slash + /// is added. Any other malformed value deactivates the override instead + /// of failing endpoint activation. + static func profile(rawValue: String?) -> CmxIrohEndpointRelayProfile? { + guard var url = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !url.isEmpty else { + return nil + } + if !url.hasSuffix("/") { url += "/" } + guard let relay = try? CmxIrohCustomRelay(url: url), + let custom = try? CmxIrohCustomRelayProfile(relays: [relay]) else { + return nil + } + return CmxIrohEndpointRelayProfile(customProfile: custom) + } + #endif +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverrideDiagnostics.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverrideDiagnostics.swift new file mode 100644 index 000000000000..15263a7b7a1e --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDebugRelayOverrideDiagnostics.swift @@ -0,0 +1,19 @@ +/// Read-only diagnostics view of ``CmxIrohDebugRelayOverride`` for local +/// debug surfaces (the `iroh_diag` socket verb). +public struct CmxIrohDebugRelayOverrideDiagnostics: Sendable { + /// Creates a diagnostics view over the process-wide override state. + public init() {} + + /// The environment/defaults key that activates the override, echoed in + /// diagnostics output so operators know which knob produced the value. + public var overrideKey: String { + CmxIrohDebugRelayOverride.key + } + + /// The active override's single relay URL. Nil when the override is + /// inactive, and always nil in release builds, where the override + /// compiles away. + public var activeRelayURL: String? { + CmxIrohDebugRelayOverride.activeProfile()?.activeRelays.first?.url + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift index 9e2cefa514df..3cfe3cbbbb7f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift @@ -264,8 +264,6 @@ extension CmxIrohLibError: DiagnosticFailureProviding { switch self { case .invalidEndpointIdentity, .remoteIdentityMismatch: .identityMismatch - case .expiredRelayCredential: - .credentialUnavailable case .unmanagedRelayURL, .unsupportedRelayIdentifier: .policyUnavailable case .unexpectedALPN, .invalidReceiveLimit: @@ -288,22 +286,12 @@ extension CmxIrohRelayPolicyServiceError: DiagnosticFailureProviding { public var diagnosticFailureKind: DiagnosticFailureKind { switch self { case .brokerUnavailable: .policyUnavailable - case .managedCredentialUnavailable: .credentialUnavailable case .preferenceRollback: .policyUnavailable case .superseded: .superseded } } } -extension CmxIrohRelayCredentialCoordinatorError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .inactive: .endpointUnavailable - case .relayFleetMismatch: .policyUnavailable - } - } -} - extension CmxIrohRegistryContextError: DiagnosticFailureProviding { public var diagnosticFailureKind: DiagnosticFailureKind { switch self { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift index fe178327b1c6..80382b64f70c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift @@ -35,11 +35,6 @@ public struct CmxIrohEffectiveRelayPolicy: Equatable, Sendable { /// Monotonic broker preference revision, when one was restored. public let preferenceRevision: Int64? - /// The endpoint-scoped credential returned with this exact broker policy. - /// - /// Kept internal so tokens cannot cross the transport/settings boundary. - let relayBootstrap: CmxIrohRelayTokenResponse? - init( endpointRelayProfile: CmxIrohEndpointRelayProfile, managedSnapshot: CmxIrohRelayPolicySnapshot?, @@ -50,8 +45,7 @@ public struct CmxIrohEffectiveRelayPolicy: Equatable, Sendable { missingCredentialRelayIDs: Set = [], source: CmxIrohRelayPolicySource, usedCachedPolicy: Bool, - preferenceRevision: Int64?, - relayBootstrap: CmxIrohRelayTokenResponse? = nil + preferenceRevision: Int64? ) { self.endpointRelayProfile = endpointRelayProfile self.managedSnapshot = managedSnapshot @@ -63,6 +57,5 @@ public struct CmxIrohEffectiveRelayPolicy: Equatable, Sendable { self.source = source self.usedCachedPolicy = usedCachedPolicy self.preferenceRevision = preferenceRevision - self.relayBootstrap = relayBootstrap } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift index c15df53f582a..775f54e1e84a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift @@ -28,17 +28,16 @@ public protocol CmxIrohEndpoint: Sendable { alpn: Data ) async throws -> any CmxIrohConnection - /// Accepts the next connection that negotiated a configured ALPN. + /// Accepts the next incoming connection attempt without completing its + /// server-side handshake. /// - /// - Returns: The accepted connection, or `nil` after endpoint close. - /// - Throws: A transport error for a failed handshake. - func accept() async throws -> (any CmxIrohConnection)? - - /// Replaces relay credentials without changing the EndpointID. + /// The returned attempt performs the handshake in + /// ``CmxIrohIncomingConnection/establish()``, so a peer that stops making + /// handshake progress never blocks the accept queue behind it. /// - /// - Parameter relays: The new complete managed relay set. - /// - Throws: A transport error when the update cannot be applied. - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws + /// - Returns: The incoming attempt, or `nil` after endpoint close. + /// - Throws: A transport error when the accept queue fails. + func accept() async throws -> (any CmxIrohIncomingConnection)? /// Replaces the complete managed or custom relay profile without changing EndpointID. /// @@ -65,11 +64,10 @@ public extension CmxIrohEndpoint { /// Test and alternate endpoints opt out of local advertisement by default. func localDirectAddresses() async -> [String] { [] } - /// Alternate endpoints retain managed credential refresh compatibility. - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { - guard profile.source == .managed else { - throw CmxIrohEndpointConfigurationError.unsupportedRelayProfileReplacement - } - try await replaceRelays(profile.managedRelays) + /// Test and alternate endpoints reject relay profile replacement by + /// default, so a supervisor can never commit a profile the endpoint did + /// not actually apply. + func replaceRelayProfile(_: CmxIrohEndpointRelayProfile) async throws { + throw CmxIrohEndpointConfigurationError.unsupportedRelayProfileReplacement } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift index b0adb818afc1..e83c56d6f569 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift @@ -19,11 +19,6 @@ public struct CmxIrohEndpointConfiguration: Equatable, Sendable { relayProfile.source == .managed ? relayProfile.allowedRelayURLs : [] } - /// Endpoint-scoped credentials for some or all allowed relays. - public var relays: [CmxIrohRelayConfiguration] { - relayProfile.managedRelays - } - /// Creates a validated endpoint bind configuration. /// /// - Parameters: @@ -31,18 +26,15 @@ public struct CmxIrohEndpointConfiguration: Equatable, Sendable { /// - alpns: ALPNs advertised by the endpoint. /// - bindPolicy: Ephemeral by default, or an exact required socket address. /// - managedRelayURLs: Exact relay origins permitted by app or MDM policy. - /// - relays: Current endpoint-scoped relay credentials. /// - Throws: ``CmxIrohEndpointConfigurationError`` for fleet-policy violations. public init( secretKey: CmxIrohSecretKey, alpns: [Data], bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, - managedRelayURLs: Set, - relays: [CmxIrohRelayConfiguration] + managedRelayURLs: Set ) throws { let relayProfile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: managedRelayURLs, - relays: relays + managedRelayURLs: managedRelayURLs ) self.secretKey = secretKey self.alpns = alpns diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift index 1a6a2b5dac10..78b28a0a9c5f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift @@ -3,18 +3,6 @@ public enum CmxIrohEndpointConfigurationError: Error, Equatable, Sendable { /// The relay fleet is larger than the endpoint policy permits. case tooManyRelays(Int) - /// A relay URL appears more than once. - case duplicateRelayURL(String) - - /// A credential names a relay outside the explicit fleet allowlist. - case unmanagedRelayURL(String) - - /// A verified managed selection is missing one or more relay credentials. - case incompleteManagedRelayCredentials - - /// Managed broker credentials cannot mutate a strict custom relay override. - case managedCredentialUpdateInCustomProfile - /// The endpoint implementation cannot apply a complete profile replacement. case unsupportedRelayProfileReplacement } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift index e0718237d03e..1081fb609306 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift @@ -1,6 +1,10 @@ import Foundation /// The complete relay policy installed on one Iroh endpoint generation. +/// +/// Managed relays carry no client credentials: the relay handshake proves the +/// endpoint key and the relay's server-side allow hook decides admission. +/// Custom relays may still carry a user-configured static token. public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { enum Source: Equatable, Sendable { case managed @@ -10,11 +14,6 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { struct Relay: Equatable, Sendable { let url: String let authenticationToken: String? - let expiresAt: Date? - - func isUsable(at now: Date) -> Bool { - expiresAt.map { $0 > now } ?? true - } } /// Exact relay origins accepted in peer reachability hints. @@ -22,15 +21,13 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { let source: Source let activeRelays: [Relay] - let managedRelays: [CmxIrohRelayConfiguration] /// A fail-closed profile used when a selected custom relay profile cannot /// be restored. Direct P2P stays enabled, while every relay is disabled. public static let unavailableCustomOverride = CmxIrohEndpointRelayProfile( allowedRelayURLs: [], source: .custom, - activeRelays: [], - managedRelays: [] + activeRelays: [] ) /// A fail-closed profile used when a managed relay selection cannot be @@ -38,72 +35,41 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { public static let unavailableManagedSelection = CmxIrohEndpointRelayProfile( allowedRelayURLs: [], source: .managed, - activeRelays: [], - managedRelays: [] + activeRelays: [] ) private init( allowedRelayURLs: Set, source: Source, - activeRelays: [Relay], - managedRelays: [CmxIrohRelayConfiguration] + activeRelays: [Relay] ) { self.allowedRelayURLs = allowedRelayURLs self.source = source self.activeRelays = activeRelays - self.managedRelays = managedRelays } - /// Creates a managed profile whose credentials are constrained by an - /// app-pinned or root-verified relay allowlist. - /// - /// The allowlist may contain relays without a current credential so an - /// endpoint can bind before broker refresh completes. + /// Creates a managed profile in which every allowed relay is active with + /// no client credential. /// - /// - Parameters: - /// - allowedRelayURLs: Exact managed relay origins accepted by policy. - /// - relays: Current endpoint-scoped credentials for a subset of the allowlist. + /// - Parameter allowedRelayURLs: Exact managed relay origins accepted by policy. /// - Throws: ``CmxIrohEndpointConfigurationError`` for a policy violation. - public init( - managedRelayURLs allowedRelayURLs: Set, - relays: [CmxIrohRelayConfiguration] - ) throws { - try Self.validate( - allowedRelayURLs: allowedRelayURLs, - relayURLs: relays.map(\.url) - ) + public init(managedRelayURLs allowedRelayURLs: Set) throws { + guard allowedRelayURLs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { + throw CmxIrohEndpointConfigurationError.tooManyRelays(allowedRelayURLs.count) + } self.allowedRelayURLs = allowedRelayURLs source = .managed - activeRelays = relays.map { - Relay( - url: $0.url, - authenticationToken: $0.token, - expiresAt: $0.expiresAt - ) + activeRelays = allowedRelayURLs.sorted().map { + Relay(url: $0, authenticationToken: nil) } - managedRelays = relays } - /// Creates a managed profile from one verified catalog selection and its - /// exact endpoint-scoped credential set. + /// Creates a managed profile from one verified catalog selection. /// - /// - Parameters: - /// - snapshot: Root-verified managed catalog and local selection. - /// - relays: Credentials for every selected relay and no other origin. - /// - Throws: ``CmxIrohEndpointConfigurationError`` for credential substitution. - public init( - snapshot: CmxIrohRelayPolicySnapshot, - relays: [CmxIrohRelayConfiguration] - ) throws { - let selectedURLs = snapshot.relayURLs - let credentialURLs = Set(relays.map(\.url)) - guard credentialURLs == selectedURLs else { - if let substituted = credentialURLs.subtracting(selectedURLs).first { - throw CmxIrohEndpointConfigurationError.unmanagedRelayURL(substituted) - } - throw CmxIrohEndpointConfigurationError.incompleteManagedRelayCredentials - } - try self.init(managedRelayURLs: selectedURLs, relays: relays) + /// - Parameter snapshot: Root-verified managed catalog and local selection. + /// - Throws: ``CmxIrohEndpointConfigurationError`` for a policy violation. + public init(snapshot: CmxIrohRelayPolicySnapshot) throws { + try self.init(managedRelayURLs: snapshot.relayURLs) } /// Creates a strict custom override with no managed-provider fallback. @@ -118,51 +84,8 @@ public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { activeRelays = customProfile.relays.map { Relay( url: $0.url, - authenticationToken: $0.authenticationToken, - expiresAt: nil + authenticationToken: $0.authenticationToken ) } - managedRelays = [] - } - - func replacingManagedRelays( - _ relays: [CmxIrohRelayConfiguration] - ) throws -> CmxIrohEndpointRelayProfile { - guard source == .managed else { - throw CmxIrohEndpointConfigurationError.managedCredentialUpdateInCustomProfile - } - return try CmxIrohEndpointRelayProfile( - managedRelayURLs: allowedRelayURLs, - relays: relays - ) - } - - func droppingExpiredManagedCredentials(at now: Date) throws -> CmxIrohEndpointRelayProfile { - guard source == .managed else { return self } - return try CmxIrohEndpointRelayProfile( - managedRelayURLs: allowedRelayURLs, - relays: managedRelays.filter { $0.expiresAt > now } - ) - } - - private static func validate( - allowedRelayURLs: Set, - relayURLs: [String] - ) throws { - guard allowedRelayURLs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohEndpointConfigurationError.tooManyRelays(allowedRelayURLs.count) - } - guard relayURLs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohEndpointConfigurationError.tooManyRelays(relayURLs.count) - } - var observedURLs = Set() - for url in relayURLs { - guard allowedRelayURLs.contains(url) else { - throw CmxIrohEndpointConfigurationError.unmanagedRelayURL(url) - } - guard observedURLs.insert(url).inserted else { - throw CmxIrohEndpointConfigurationError.duplicateRelayURL(url) - } - } } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift index e7b70b90435e..88734f48fd2f 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift @@ -42,17 +42,35 @@ public actor CmxIrohEndpointServer { private struct PendingAdmission { let generation: UInt64 - let remoteIdentity: CmxIrohPeerIdentity - let connection: any CmxIrohConnection + let incoming: any CmxIrohIncomingConnection let handlerTask: Task let deadlineTask: Task + /// Set once the server-side handshake completed and capacity checks + /// passed. `nil` while the attempt is still establishing. + var remoteIdentity: CmxIrohPeerIdentity? + var connection: (any CmxIrohConnection)? + /// Set when the admission deadline fired while the handshake was + /// still in flight. The slot stays occupied until the attempt + /// resolves; whichever of `registerEstablished`/`failEstablishment` + /// observes the resolution releases it. + var abandoned = false } + /// The endpoint's accept queue ended while its generation is still + /// current: the driver is gone but lifecycle state says active. Thrown + /// into the recovery path so the supervisor re-verifies the endpoint + /// instead of the accept loop dying silently. + private struct AcceptQueueEndedError: Error {} + private struct ActiveConnection { let generation: UInt64 let remoteIdentity: CmxIrohPeerIdentity let connection: any CmxIrohConnection let handlerTask: Task + /// Awaits the transport's own terminal signal for this connection and + /// releases the admission slot the moment it fires, so capacity is + /// tied to connection liveness rather than to the handler unwinding. + let closeWatcherTask: Task let sequence: UInt64 var isUsable: Bool } @@ -154,13 +172,15 @@ public actor CmxIrohEndpointServer { for admission in admissions { admission.handlerTask.cancel() admission.deadlineTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: "server_stopped" - ) + if let connection = admission.connection { + await connection.close(errorCode: 1, reason: "server_stopped") + } else { + await admission.incoming.abandon() + } } for connection in connections { connection.handlerTask.cancel() + connection.closeWatcherTask.cancel() await connection.connection.close( errorCode: 1, reason: "server_stopped" @@ -205,13 +225,25 @@ public actor CmxIrohEndpointServer { var consecutiveFailures = 0 while !Task.isCancelled, currentGeneration == generation { do { - guard let connection = try await endpoint.accept() else { return } + guard let incoming = try await endpoint.accept() else { + // Never die silently: recovery below re-verifies the + // endpoint so a closed driver is replaced instead of + // leaving a published-but-undialable generation behind. + throw AcceptQueueEndedError() + } consecutiveFailures = 0 guard currentGeneration == generation else { - await connection.close(errorCode: 1, reason: "stale_generation") + await incoming.abandon() return } - await startAdmission(connection: connection, generation: generation) + guard startAdmission(incoming: incoming, generation: generation) else { + // Admission is full. Abandoning here, on the loop, is + // deliberate backpressure: rejection work stays bounded to + // one attempt at a time instead of a remote flood minting + // unowned tasks. + await incoming.abandon() + continue + } } catch is CancellationError { return } catch { @@ -235,55 +267,35 @@ public actor CmxIrohEndpointServer { } } + /// Starts one admission, or returns `false` when admission is at capacity + /// and the caller must abandon the attempt itself. private func startAdmission( - connection: any CmxIrohConnection, + incoming: any CmxIrohIncomingConnection, generation: UInt64 - ) async { - let remoteIdentity = await connection.remoteIdentity() - guard currentGeneration == generation, !Task.isCancelled else { - await connection.close(errorCode: 1, reason: "stale_generation") - return - } + ) -> Bool { guard pendingAdmissions.count < maximumPendingAdmissions else { - await connection.close(errorCode: 1, reason: "admission_capacity") - return - } - let pendingForIdentity = pendingAdmissions.values.lazy.filter { - $0.remoteIdentity == remoteIdentity - }.count - guard pendingForIdentity < maximumPendingAdmissionsPerIdentity else { - await connection.close( - errorCode: 1, - reason: "admission_identity_capacity" - ) - return - } - let activeForIdentity = activeConnections.values.lazy.filter { - $0.remoteIdentity == remoteIdentity - }.count - let hasReplaceableConnection = activeConnections.values.contains { - $0.remoteIdentity == remoteIdentity && !$0.isUsable - } - let canReserveReplacement = pendingForIdentity == 0 - && maximumConnectionsPerIdentity > 1 - && activeForIdentity >= maximumConnectionsPerIdentity - && hasReplaceableConnection - guard pendingAdmissions.count + activeConnections.count < maximumConnections - || canReserveReplacement else { - await connection.close(errorCode: 1, reason: "connection_capacity") - return - } - guard pendingForIdentity + activeForIdentity < maximumConnectionsPerIdentity - || canReserveReplacement else { - await connection.close( - errorCode: 1, - reason: "connection_identity_capacity" - ) - return + return false } let id = UUID() let handler = handler + // The handshake runs inside this per-connection task, bounded by the + // admission deadline below and by the driver's own handshake timeout, + // so a peer that stops making progress costs only its own slot. let handlerTask = Task { [weak self] in + let connection: any CmxIrohConnection + do { + connection = try await incoming.establish() + } catch { + await self?.failEstablishment(id) + return + } + guard let self else { + await connection.close(errorCode: 1, reason: "server_deallocated") + return + } + guard await self.registerEstablished(id, connection: connection) else { + return + } do { try await handler( connection, @@ -297,9 +309,9 @@ public actor CmxIrohEndpointServer { } ) ) - await self?.finishHandler(id, error: nil) + await self.finishHandler(id, error: nil) } catch { - await self?.finishHandler(id, error: error) + await self.finishHandler(id, error: error) } } let clock = clock @@ -313,28 +325,129 @@ public actor CmxIrohEndpointServer { } pendingAdmissions[id] = PendingAdmission( generation: generation, - remoteIdentity: remoteIdentity, - connection: connection, + incoming: incoming, handlerTask: handlerTask, deadlineTask: deadlineTask ) + return true + } + + /// Records a completed handshake against its pending admission and applies + /// the identity-scoped capacity policy that used to run before the (then + /// inline) handshake. Returns whether the connection may proceed to the + /// application handler; a rejected or expired connection is closed here. + private func registerEstablished( + _ id: UUID, + connection: any CmxIrohConnection + ) async -> Bool { + let remoteIdentity = await connection.remoteIdentity() + if let admission = pendingAdmissions[id], admission.abandoned { + // The admission deadline fired while this handshake was in + // flight; its resolution releases the slot it kept occupied. + pendingAdmissions[id] = nil + await connection.close(errorCode: 1, reason: "admission_timeout") + return false + } + guard var admission = pendingAdmissions[id], + admission.generation == currentGeneration, + admission.connection == nil else { + // Timed out, superseded, or the server stopped while establishing. + await connection.close(errorCode: 1, reason: "admission_expired") + return false + } + let pendingForIdentity = pendingAdmissions.lazy.filter { + $0.key != id && $0.value.remoteIdentity == remoteIdentity + }.count + guard pendingForIdentity < maximumPendingAdmissionsPerIdentity else { + await rejectEstablished( + id, + connection: connection, + reason: "admission_identity_capacity" + ) + return false + } + let activeForIdentity = activeConnections.values.lazy.filter { + $0.remoteIdentity == remoteIdentity + }.count + // A TLS-authenticated peer may always run one replacement admission + // against its own connections: capacity held by a dead predecessor + // must not refuse the redial until the idle timer notices the death. + // The reservation is identity-scoped (a stranger has nothing of its + // own to replace, so it can never preempt an occupied slot) and is + // bounded to one in flight by the pending-per-identity check above. + let canReserveReplacement = pendingForIdentity == 0 + && activeForIdentity > 0 + let otherPendingCount = pendingAdmissions.count - 1 + guard otherPendingCount + activeConnections.count < maximumConnections + || canReserveReplacement else { + await rejectEstablished( + id, + connection: connection, + reason: "connection_capacity" + ) + return false + } + guard pendingForIdentity + activeForIdentity < maximumConnectionsPerIdentity + || canReserveReplacement else { + await rejectEstablished( + id, + connection: connection, + reason: "connection_identity_capacity" + ) + return false + } + admission.remoteIdentity = remoteIdentity + admission.connection = connection + pendingAdmissions[id] = admission + return true + } + + private func rejectEstablished( + _ id: UUID, + connection: any CmxIrohConnection, + reason: String + ) async { + if let admission = pendingAdmissions.removeValue(forKey: id) { + admission.deadlineTask.cancel() + } + await connection.close(errorCode: 1, reason: reason) + } + + private func failEstablishment(_ id: UUID) async { + guard let admission = pendingAdmissions.removeValue(forKey: id) else { + return + } + admission.deadlineTask.cancel() + // An abandoned attempt was already refused at its deadline; removing + // the entry above is what releases the slot its resolution freed. + if !admission.abandoned { + await admission.incoming.abandon() + } } private func markAdmitted(_ id: UUID, generation: UInt64) async -> Bool { guard currentGeneration == generation, - let admission = pendingAdmissions.removeValue(forKey: id), - admission.generation == generation else { + let admission = pendingAdmissions[id], + admission.generation == generation, + let remoteIdentity = admission.remoteIdentity, + let connection = admission.connection else { return false } + pendingAdmissions[id] = nil admission.deadlineTask.cancel() // An authenticated replacement may use the one admission reservation - // above the steady identity bound. Reclaim only the oldest connection - // that never became application-usable. A known-good session is retired - // exclusively by markUsable below. + // above the steady identity bound. Reclaim the oldest connection that + // never became application-usable; when only usable predecessors + // exist (a dead peer's session stays "usable" until the idle timer + // notices), admission proceeds one over the bound and markUsable + // below retires the predecessor after the replacement proves itself, + // so a live session is never torn down for an unproven redial and a + // dead one stops pinning capacity. An identity with no connection of + // its own can never exceed the bounds. let activeForIdentity = activeConnections.filter { _, connection in connection.generation == generation - && connection.remoteIdentity == admission.remoteIdentity + && connection.remoteIdentity == remoteIdentity } let requiresReplacement = activeConnections.count >= maximumConnections || activeForIdentity.count >= maximumConnectionsPerIdentity @@ -343,23 +456,36 @@ public actor CmxIrohEndpointServer { .filter { !$0.value.isUsable } .min { $0.value.sequence < $1.value.sequence } : nil - if requiresReplacement, replaced == nil { + if requiresReplacement, replaced == nil, activeForIdentity.isEmpty { + // Capacity filled between registerEstablished and this marker and + // the identity has nothing of its own to replace. The pending + // entry is already removed, so the server still owns the + // established connection here and must close it before disowning + // the admission; returning without closing would leave a live + // QUIC connection outside every capacity table. + await connection.close(errorCode: 1, reason: "connection_capacity") return false } if let replaced { activeConnections[replaced.key] = nil } nextConnectionSequence &+= 1 + let closeWatcherTask = Task { [weak self] in + await connection.waitUntilClosed() + await self?.releaseClosedConnection(id) + } activeConnections[id] = ActiveConnection( generation: generation, - remoteIdentity: admission.remoteIdentity, - connection: admission.connection, + remoteIdentity: remoteIdentity, + connection: connection, handlerTask: admission.handlerTask, + closeWatcherTask: closeWatcherTask, sequence: nextConnectionSequence, isUsable: false ) if let replaced { replaced.value.handlerTask.cancel() + replaced.value.closeWatcherTask.cancel() await replaced.value.connection.close( errorCode: 0, reason: "superseded_unready_connection" @@ -388,6 +514,7 @@ public actor CmxIrohEndpointServer { activeConnections[id] = promoted for connection in superseded.values { connection.handlerTask.cancel() + connection.closeWatcherTask.cancel() await connection.connection.close( errorCode: 0, reason: "superseded_connection" @@ -399,15 +526,18 @@ public actor CmxIrohEndpointServer { private func finishHandler(_ id: UUID, error: (any Error)?) async { if let admission = pendingAdmissions.removeValue(forKey: id) { admission.deadlineTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: error == nil ? "admission_incomplete" : "admission_failed" - ) + let reason = error == nil ? "admission_incomplete" : "admission_failed" + if let connection = admission.connection { + await connection.close(errorCode: 1, reason: reason) + } else { + await admission.incoming.abandon() + } return } guard let active = activeConnections.removeValue(forKey: id) else { return } + active.closeWatcherTask.cancel() if error != nil { await active.connection.close( errorCode: 1, @@ -416,15 +546,35 @@ public actor CmxIrohEndpointServer { } } - private func timeOutAdmission(_ id: UUID) async { - guard let admission = pendingAdmissions.removeValue(forKey: id) else { + /// Releases the admission slot as soon as the transport reports the + /// connection terminal (peer close, transport error, or its own timeout), + /// instead of when the handler serving it eventually unwinds. + private func releaseClosedConnection(_ id: UUID) { + guard let active = activeConnections.removeValue(forKey: id) else { return } + active.handlerTask.cancel() + active.closeWatcherTask.cancel() + } + + private func timeOutAdmission(_ id: UUID) async { + guard var admission = pendingAdmissions[id] else { return } admission.handlerTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: "admission_timeout" - ) + if let connection = admission.connection { + pendingAdmissions[id] = nil + await connection.close(errorCode: 1, reason: "admission_timeout") + return + } + // The handshake is still in flight, and cancellation does not reach + // the native attempt: a consumed `Incoming` cannot be refused, and + // the bindings do not propagate task cancellation into the driver. + // Refuse the dialer fast, but keep the slot occupied until + // establish() itself resolves (the driver's own handshake/idle + // timeout bounds that), so a remote peer cannot mint more live + // handshake work than `maximumPendingAdmissions` permits. + admission.abandoned = true + pendingAdmissions[id] = admission + await admission.incoming.abandon() } private func cancelConnections( @@ -438,7 +588,11 @@ public actor CmxIrohEndpointServer { for admission in stale.values { admission.handlerTask.cancel() admission.deadlineTask.cancel() - await admission.connection.close(errorCode: 1, reason: reason) + if let connection = admission.connection { + await connection.close(errorCode: 1, reason: reason) + } else { + await admission.incoming.abandon() + } } let active = activeConnections.filter { _, connection in connection.generation != retainedGeneration @@ -446,6 +600,7 @@ public actor CmxIrohEndpointServer { for id in active.keys { activeConnections[id] = nil } for connection in active.values { connection.handlerTask.cancel() + connection.closeWatcherTask.cancel() await connection.connection.close(errorCode: 1, reason: reason) } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift index a8ce2875377d..54f929d2a2b5 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift @@ -284,101 +284,9 @@ public actor CmxIrohEndpointSupervisor { return try await activate() } - /// Installs a fresh relay set on the live endpoint before committing it for future binds. - /// - /// The concrete endpoint must add replacement credentials before removing - /// stale credentials. A failed update leaves this supervisor's last-known - /// good configuration unchanged. - /// - /// - Parameter relays: The complete new relay credential set. - /// - Throws: A fleet validation or endpoint update error. - public func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws { - try await replaceRelays( - relays, - expectedIdentity: Optional.none - ) - } - - /// Installs relay credentials only on the active endpoint identity that requested them. - /// - /// A lifecycle transition during the update leaves the next generation's - /// configuration unchanged. This prevents a delayed token response for an - /// old binding from being committed to a replacement endpoint. - public func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity - ) async throws { - try await replaceRelays(relays, expectedIdentity: Optional(expectedIdentity)) - } - - private func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity? - ) async throws { - let candidateProfile = try configuration.relayProfile.replacingManagedRelays(relays) - let candidateConfiguration = CmxIrohEndpointConfiguration( - secretKey: configuration.secretKey, - alpns: configuration.alpns, - bindPolicy: configuration.bindPolicy, - relayProfile: candidateProfile - ) - guard let endpoint else { - guard expectedIdentity == nil else { - throw CmxIrohEndpointSupervisorError.inactive - } - configuration = candidateConfiguration - return - } - let revision = lifecycleRevision - if let expectedIdentity { - let actualIdentity = await endpoint.identity() - guard lifecycleRevision == revision, - snapshot.state == .active, - actualIdentity == expectedIdentity else { - throw CmxIrohEndpointSupervisorError.superseded - } - } - let previousAddress = await endpoint.address() - guard lifecycleRevision == revision, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.superseded - } - let priorRelayReadyGeneration = relayReadyGeneration - relayReadyGeneration = nil - do { - try await endpoint.replaceRelays(relays) - } catch { - if lifecycleRevision == revision, - snapshot.state == .active, - priorRelayReadyGeneration == snapshot.runtimeGeneration { - markRelayReady(generation: snapshot.runtimeGeneration) - } - throw error - } - let updatedAddress = await endpoint.address() - guard lifecycleRevision == revision, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.superseded - } - configuration = candidateConfiguration - if Self.hasUsableRelayHint(updatedAddress) { - markRelayReady(generation: snapshot.runtimeGeneration) - } - // The endpoint's address watcher may observe the new home relay while - // `replaceRelays` is suspended, before the endpoint commits the matching - // allowlist. That early event is filtered by the old profile and may be - // the only native address callback. Republish after both endpoint and - // supervisor configuration commit so owners re-read one coherent route. - if updatedAddress != previousAddress { - publish(.networkChanged(runtimeGeneration: snapshot.runtimeGeneration)) - } - } - - /// Installs a complete managed selection or custom relay override live. - /// - /// The endpoint keeps its stable key and adds replacement relays before it - /// removes stale relays. A failed update leaves the supervisor's future bind - /// configuration unchanged. - /// - /// - Parameter profile: Exact relay allowlist and active configurations. + /// Installs a fresh relay profile on the live endpoint before committing + /// it for future binds. A failed update leaves this supervisor's + /// last-known good configuration unchanged. public func replaceRelayProfile( _ profile: CmxIrohEndpointRelayProfile ) async throws { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEstablishedIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEstablishedIncomingConnection.swift new file mode 100644 index 000000000000..2c6414b3d260 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEstablishedIncomingConnection.swift @@ -0,0 +1,25 @@ +/// Wraps an already-established connection as an incoming attempt. +/// +/// Alternate transports and test endpoints that produce finished connections +/// use this to satisfy the accept contract; ``establish()`` returns +/// immediately. +public struct CmxIrohEstablishedIncomingConnection: CmxIrohIncomingConnection { + private let connection: any CmxIrohConnection + + /// Wraps `connection` as an attempt whose handshake already completed. + public init(_ connection: any CmxIrohConnection) { + self.connection = connection + } + + /// Returns the wrapped connection immediately; the handshake completed + /// before this attempt was created. + public func establish() async throws -> any CmxIrohConnection { + connection + } + + /// Closes the wrapped connection; with the handshake already complete, + /// closing is the only way to release the attempt. + public func abandon() async { + await connection.close(errorCode: 1, reason: "admission_abandoned") + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift index fb5b8f9f60b2..258f88221809 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift @@ -1,6 +1,6 @@ /// Trust-broker operations required by a Mac host runtime. public protocol CmxIrohHostBrokerServing: CmxIrohDiscoveryServing, - CmxIrohRelayTokenServing, CmxIrohBindingRevoking + CmxIrohBindingRevoking { /// Checks a caller-owned broker floor without performing network work. func preflight(operation: CmxIrohBrokerOperation) async throws diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift index 77bf8b8d3600..bd35fa2eb462 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift @@ -94,7 +94,6 @@ extension CmxIrohHostRuntime { after: error, expectedEndpointID: expectedEndpointID, confirmedBinding: nil, - relayBootstrap: nil, allowFallback: allowCachedFallback ) } @@ -116,7 +115,6 @@ extension CmxIrohHostRuntime { after: error, expectedEndpointID: expectedEndpointID, confirmedBinding: nil, - relayBootstrap: nil, allowFallback: allowCachedFallback ) } @@ -167,7 +165,6 @@ extension CmxIrohHostRuntime { after: error, expectedEndpointID: expectedEndpointID, confirmedBinding: registration.binding, - relayBootstrap: nil, allowFallback: allowCachedFallback ) } @@ -205,7 +202,6 @@ extension CmxIrohHostRuntime { pairingEnabled: discovered.pairingEnabled, grantVerificationKeys: discovery.grantVerificationKeys, attestation: attestation, - relayBootstrap: configuration.cachedRelayCredential, lanRendezvous: discovery.lanRendezvous, routePathHints: discovered.pathHints, registrationRetryAfterSeconds: nil @@ -272,11 +268,63 @@ extension CmxIrohHostRuntime { return discovery } + /// Returns a start policy from the persisted last-good broker policy when + /// it still cryptographically verifies for this exact account, identity, + /// endpoint, and host settings. Any mismatch is a silent cache miss so + /// activation falls back to the blocking authenticated resolve. + func validatedCachedStartPolicy( + expectedEndpointID: CmxIrohPeerIdentity + ) -> ResolvedPolicy? { + guard let cached = configuration.cachedHostPolicy else { return nil } + do { + try validateCachedPolicy(cached, endpointID: expectedEndpointID) + } catch { + return nil + } + return ResolvedPolicy( + registration: nil, + discovery: nil, + binding: cached.binding, + pairingEnabled: cached.pairingEnabled, + grantVerificationKeys: cached.grantVerificationKeys, + attestation: cached.endpointAttestation, + lanRendezvous: cached.lanRendezvous, + routePathHints: [], + registrationRetryAfterSeconds: nil + ) + } + + /// Whether this activation binds its endpoint with the managed relays + /// withheld and installs them only after ``start()`` has an acknowledged + /// broker registration. True exactly when the profile is managed (custom + /// relays are user-operated, not admission-gated by the cmux broker) and + /// no cached policy verifies for this endpoint: a fresh endpoint that + /// dials an admission-gated relay before its registration lands is denied + /// by the relay's allow hook, and that deny is negatively cached, so one + /// lost race costs the whole activation. The endpoint identity is derived + /// from the configured secret key, so the decision is made before the + /// bind it governs. + func withholdsManagedRelaysUntilRegistered( + for profile: CmxIrohEndpointRelayProfile + ) -> Bool { + guard profile.source == .managed, !profile.activeRelays.isEmpty else { + return false + } + guard let expectedEndpointID = configuration.identity.peerIdentity else { + // Without a derivable identity no cached policy can verify; + // withholding is the safe default (the bind itself decides + // whether the key is usable at all). + return true + } + return validatedCachedStartPolicy( + expectedEndpointID: expectedEndpointID + ) == nil + } + func cachedPolicy( after error: any Error, expectedEndpointID: CmxIrohPeerIdentity, confirmedBinding: CmxIrohBrokerBinding?, - relayBootstrap: CmxIrohRelayTokenResponse?, allowFallback: Bool ) throws -> ResolvedPolicy { if let confirmedBinding, let localBinding, @@ -305,7 +353,6 @@ extension CmxIrohHostRuntime { pairingEnabled: cached.pairingEnabled, grantVerificationKeys: cached.grantVerificationKeys, attestation: cached.endpointAttestation, - relayBootstrap: relayBootstrap ?? configuration.cachedRelayCredential, lanRendezvous: cached.lanRendezvous, routePathHints: [], registrationRetryAfterSeconds: ( @@ -365,15 +412,6 @@ extension CmxIrohHostRuntime { } } - func cachedRelayConfigurations() -> [CmxIrohRelayConfiguration] { - guard let cached = configuration.cachedRelayCredential, - Set(cached.relayFleet) == managedRelayURLs, - cached.relayFleet.count == managedRelayURLs.count else { - return [] - } - return (try? cached.relayConfigurations(now: now())) ?? [] - } - func startConnectivityObservation( engine: CmxConnectivityEngine, revision: UInt64 @@ -541,7 +579,7 @@ extension CmxIrohHostRuntime { let previousBinding = localBinding else { return } do { let endpointID = try await connectivityEngine.localEndpointIdentity() - if !forcePublication { + if !forcePublication, !initialPublicationPending { let state = try await registrationPublicationState( engine: connectivityEngine, expectedEndpointID: endpointID @@ -560,9 +598,16 @@ extension CmxIrohHostRuntime { revision: revision, allowCachedFallback: false ) - guard policy.binding.bindingID == previousBinding.bindingID else { - throw CmxIrohHostRuntimeError.invalidLocalBinding + if policy.binding.bindingID != previousBinding.bindingID { + guard allowsReplacedBindingAdoption else { + throw CmxIrohHostRuntimeError.invalidLocalBinding + } + // A cache-first activation discovered its persisted binding + // was replaced server-side. Adopt the authenticated result in + // place, exactly as the blocking activation path would have. + try await adoptReplacedBinding(policy: policy, revision: revision) } + allowsReplacedBindingAdoption = false await admissionController.update( keys: policy.grantVerificationKeys, acceptor: grantPeer(for: policy.binding), @@ -570,12 +615,47 @@ extension CmxIrohHostRuntime { ) try requireCurrent(revision) localBinding = policy.binding + if currentSnapshot.bindingID != policy.binding.bindingID { + currentSnapshot = CmxIrohHostRuntimeSnapshot( + state: currentSnapshot.state, + endpointID: currentSnapshot.endpointID, + bindingID: policy.binding.bindingID + ) + } endpointAttestation = policy.attestation ?? endpointAttestation lanRendezvous = policy.lanRendezvous guard let registration = policy.registration, let discovery = policy.discovery else { throw CmxIrohHostRuntimeError.invalidLocalBinding } + if initialPublicationPending { + let ready = await initialPublicationReady( + engine: connectivityEngine + ) + try requireCurrent(revision) + guard ready else { + // The first publication of this lifecycle stays gated on + // a verified usable relay path; the authenticated + // reconcile above already applied admission policy, + // binding adoption, and renewal scheduling. + registrationRefreshFailureCount = 0 + completedSuccessfully = true + scheduleRegistrationRenewal( + binding: registration.binding, + revision: revision + ) + // Re-arm the ready gate: this round may be the one the + // gate handed its deferred publication to (consuming + // itself), and readiness can return without any + // network-change event (a relay reconnect iroh does not + // re-announce). While the first publication is pending, a + // relay-readiness owner must always exist; the renewal + // deadline above is cadence-bound and can be nil for a + // stale binding. + scheduleInitialPublication(revision: revision) + return + } + } await handleBinding(registration, discovery, policy.attestation) try requireCurrent(revision) await handleRoute(policy.binding, policy.routePathHints) @@ -593,6 +673,7 @@ extension CmxIrohHostRuntime { revision: revision ) registrationRefreshFailureCount = 0 + initialPublicationPending = false completedSuccessfully = true scheduleRegistrationRenewal( binding: registration.binding, @@ -634,6 +715,31 @@ extension CmxIrohHostRuntime { } } + /// Rebinds binding-scoped components to an authenticated replacement + /// binding. `CmxIrohAdmissionController.update` already propagates the new + /// acceptor to online and offline admission. + private func adoptReplacedBinding( + policy _: ResolvedPolicy, + revision: UInt64 + ) async throws { + try requireCurrent(revision) + // The startup ready gate was armed with the superseded cached binding. + // Cancel and drain it before rebinding; the deferred first publication + // is re-armed below. + if let staleReadyGate = initialPublicationTask { + staleReadyGate.cancel() + initialPublicationTask = nil + await staleReadyGate.value + try requireCurrent(revision) + } + if initialPublicationPending { + // The drained gate owned the relay-readiness wait for the deferred + // first publication. Re-arm it so the endpoint still publishes + // once the relay becomes usable. + scheduleInitialPublication(revision: revision) + } + } + static func seconds(_ date: Date) -> Int64? { let value = date.timeIntervalSince1970 guard value.isFinite, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift index f0e0e25254c7..f67f68e2df33 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift @@ -8,8 +8,7 @@ extension CmxIrohHostRuntime { } ?? managedRelayURLs try await replaceRelayProfile( policy.endpointRelayProfile, - managedRelayURLs: verifiedManagedURLs, - relayBootstrap: policy.relayBootstrap + managedRelayURLs: verifiedManagedURLs ) } @@ -19,16 +18,20 @@ extension CmxIrohHostRuntime { ) async throws { try await replaceRelayProfile( profile, - managedRelayURLs: managedRelayURLs, - relayBootstrap: nil + managedRelayURLs: managedRelayURLs ) } private func replaceRelayProfile( _ profile: CmxIrohEndpointRelayProfile, - managedRelayURLs replacementManagedURLs: Set, - relayBootstrap: CmxIrohRelayTokenResponse? + managedRelayURLs replacementManagedURLs: Set ) async throws { + // A debug-only forced relay pins every profile installation, so a + // broker policy refresh cannot displace the test relay mid-run. + var profile = profile + if let debugOverride = CmxIrohDebugRelayOverride.activeProfile() { + profile = debugOverride + } guard lifecyclePhase == .active, let connectivityEngine, let binding = localBinding else { @@ -42,54 +45,35 @@ extension CmxIrohHostRuntime { throw CmxIrohHostRuntimeError.relayFleetMismatch } let revision = lifecycleRevision + let previousRelayURLs = currentEndpointRelayProfile?.allowedRelayURLs + ?? configuration.endpointRelayProfile?.allowedRelayURLs + ?? [] - relayActivationTask?.cancel() - relayActivationTask = nil - await relayCoordinator?.deactivate() - relayCoordinator = nil - if profile.source == .managed, !profile.allowedRelayURLs.isEmpty { - let refreshSchedule = CmxIrohRelayRefreshSchedule( - role: .host, - endpointIdentity: binding.endpointID - ) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, - broker: broker, - managedRelayURLs: replacementManagedURLs, - selectedRelayURLs: profile.allowedRelayURLs, - jitter: { now, refreshAfter in - refreshSchedule.deadline(now: now, refreshAfter: refreshAfter) - }, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, binding) - } - ) - relayCoordinator = coordinator - do { - try await coordinator.activateManagedPolicy( - bindingID: binding.bindingID, - endpointIdentity: binding.endpointID, - profile: profile, - bootstrap: relayBootstrap - ) - } catch { - await coordinator.deactivate() - if relayCoordinator === coordinator { - relayCoordinator = nil - } - throw error - } - } else { - try await connectivityEngine.replaceRelayProfile( - profile, - expectedIdentity: binding.endpointID - ) - } + try await connectivityEngine.replaceRelayProfile( + profile, + expectedIdentity: binding.endpointID + ) try requireCurrent(revision) managedRelayURLs = replacementManagedURLs currentEndpointRelayProfile = profile await admissionController?.updateManagedRelayURLs(replacementManagedURLs) try requireCurrent(revision) + + // A changed relay allowlist changes how this host is dialed, so the + // registration must be republished. This is the recovery path for a + // host that activated during a relay policy outage (zero relays, + // direct-only route) and only regained a managed relay when a later + // policy refresh succeeded: without a forced round here nothing owns + // that republication, and the host stays unreachable for remote + // clients until an unrelated network change fires (cmux#10873). + // Unchanged reinstalls (every periodic refresh success re-applies the + // effective policy) schedule nothing. + if profile.allowedRelayURLs != previousRelayURLs { + scheduleRegistrationRefresh( + revision: revision, + forcePublication: true + ) + } } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift index 8ec12fc57b44..0739d9954fb0 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift @@ -86,8 +86,10 @@ extension CmxIrohHostRuntime { registrationRefreshPendingForcesPublication = false registrationRefreshEnabled = false registrationRefreshFailureCount = 0 - relayActivationTask?.cancel() - relayActivationTask = nil + initialPublicationTask?.cancel() + initialPublicationTask = nil + initialPublicationPending = false + allowsReplacedBindingAdoption = false lanPublicationGeneration &+= 1 lanPublicationTask?.cancel() lanPublicationTask = nil @@ -98,8 +100,6 @@ extension CmxIrohHostRuntime { for task in activePathObservationTasks.values { task.cancel() } activePathObservationTasks.removeAll(keepingCapacity: false) publishSelectedPathChange() - await relayCoordinator?.deactivate() - relayCoordinator = nil await offlineSessions?.invalidate() offlineSessions = nil await onlineAdmissionRegistry?.stop() diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift index c70ac5fbb0a6..b72bfd71fe40 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift @@ -22,10 +22,6 @@ public actor CmxIrohHostRuntime { /// Persistent identity and credential deletion belongs to the caller and /// must remain conditional on a successfully queued sign-out revocation. public typealias DeactivationHandler = @Sendable (_ bindingID: String?) async -> Void - public typealias RelayCredentialHandler = @Sendable ( - _ response: CmxIrohRelayTokenResponse, - _ binding: CmxIrohBrokerBindingMetadata - ) async -> Void public typealias LANRefreshHandler = @Sendable () async -> Void public typealias LANDirectAddressProvider = @Sendable () async -> [String] public typealias LANPolicyHandler = @Sendable ( @@ -40,7 +36,6 @@ public actor CmxIrohHostRuntime { let pairingEnabled: Bool let grantVerificationKeys: CmxIrohGrantVerificationKeySet let attestation: CmxIrohEndpointAttestationResponse? - let relayBootstrap: CmxIrohRelayTokenResponse? let lanRendezvous: CmxIrohLANRendezvous let routePathHints: [CmxIrohPathHint] let registrationRetryAfterSeconds: Int? @@ -74,11 +69,13 @@ public actor CmxIrohHostRuntime { let registrationClock: any CmxIrohRelayClock let registrationRetrySchedule: CmxIrohRetrySchedule let registrationRetryJitter: @Sendable () -> Double + /// One bounded signal-or-deadline window for the startup relay-readiness + /// wait. A timeout keeps the endpoint unpublished and retries the wait. + let relayReadinessTimeout: Duration let handleTransport: TransportHandler let handleBinding: BindingHandler let handleRoute: RouteHandler let handleDeactivation: DeactivationHandler - let handleRelayCredential: RelayCredentialHandler let handleLANRefresh: LANRefreshHandler let handleLANPolicy: LANPolicyHandler @@ -86,13 +83,19 @@ public actor CmxIrohHostRuntime { var lifecyclePhase = LifecyclePhase.inactive var signOutOperation: Task? var connectivityEngine: CmxConnectivityEngine? - var relayCoordinator: CmxIrohRelayCredentialCoordinator? var endpointServer: CmxIrohEndpointServer? var admissionController: CmxIrohAdmissionController? var onlineAdmissionRegistry: CmxIrohOnlineAdmissionRegistry? var offlineSessions: CmxIrohOfflinePairingSessions? var connectivityEventTask: Task? - var relayActivationTask: Task? + var initialPublicationTask: Task? + /// True while activation still owes the first ready publication. It makes + /// the next refresh round publish even when reachability is unchanged. + var initialPublicationPending = false + /// True only between a cache-first activation and its first completed live + /// resolve, allowing that resolve to adopt a replaced broker binding in + /// place instead of failing closed. + var allowsReplacedBindingAdoption = false var lanPublicationTask: Task? var lanPublicationGeneration: UInt64 = 0 var registrationRefreshTask: Task? @@ -131,11 +134,11 @@ public actor CmxIrohHostRuntime { registrationRetryJitter: @escaping @Sendable () -> Double = { Double.random(in: 0 ... 1) }, + relayReadinessTimeout: Duration = .seconds(15), handleTransport: @escaping TransportHandler, handleBinding: @escaping BindingHandler = { _, _, _ in }, handleRoute: @escaping RouteHandler = { _, _ in }, handleDeactivation: @escaping DeactivationHandler = { _ in }, - handleRelayCredential: @escaping RelayCredentialHandler = { _, _ in }, handleLANRefresh: @escaping LANRefreshHandler = {}, handleLANPolicy: @escaping LANPolicyHandler = { _, _ in } ) { @@ -149,11 +152,11 @@ public actor CmxIrohHostRuntime { self.registrationClock = registrationClock self.registrationRetrySchedule = registrationRetrySchedule self.registrationRetryJitter = registrationRetryJitter + self.relayReadinessTimeout = relayReadinessTimeout self.handleTransport = handleTransport self.handleBinding = handleBinding self.handleRoute = handleRoute self.handleDeactivation = handleDeactivation - self.handleRelayCredential = handleRelayCredential self.handleLANRefresh = handleLANRefresh self.handleLANPolicy = handleLANPolicy managedRelayURLs = configuration.managedRelayURLs @@ -161,8 +164,23 @@ public actor CmxIrohHostRuntime { } - /// Activates connectivity and resolves authenticated broker policy before any cached fallback. + /// Activates connectivity, restoring a verified cached policy immediately + /// when one matches this binding, and reconciles authenticated broker + /// policy in the background. Publication of the binding and route hints + /// waits for a usable home relay so the Mac is never + /// discoverable-but-undialable. public func start() async throws { + try await start(debugRelayOverride: CmxIrohDebugRelayOverride.activeProfile()) + } + + /// The injectable core of ``start()``. + /// + /// `debugRelayOverride` is the DEBUG-only forced relay, read once from + /// the ``CmxIrohDebugRelayOverride`` funnel by the public entrypoint so + /// tests can inject it deterministically. + func start( + debugRelayOverride: CmxIrohEndpointRelayProfile? + ) async throws { guard lifecyclePhase.allowsStart else { throw CmxIrohHostRuntimeError.alreadyActive } @@ -173,6 +191,8 @@ public actor CmxIrohHostRuntime { registrationRefreshPendingForcesPublication = false registrationRefreshEnabled = false registrationRefreshFailureCount = 0 + initialPublicationPending = false + allowsReplacedBindingAdoption = false currentSnapshot = CmxIrohHostRuntimeSnapshot( state: .starting, endpointID: nil, @@ -180,15 +200,38 @@ public actor CmxIrohHostRuntime { ) do { - let endpointRelayProfile = try (currentEndpointRelayProfile - ?? configuration.resolvedEndpointRelayProfile(now: now())) - .droppingExpiredManagedCredentials(at: now()) + // The debug-only forced relay wins over every stored or + // configured profile, including the relay-less + // `.unavailableManagedSelection` placeholder a host without a + // verifiable cached policy is configured with. The override is a + // custom profile, and custom relays are exempt from the + // withhold-until-registered ordering below, so it stays installed + // at bind and the endpoint dials the test relay immediately + // without reintroducing the pre-registration managed-relay race. + let endpointRelayProfile = try debugRelayOverride + ?? currentEndpointRelayProfile + ?? configuration.resolvedEndpointRelayProfile( + debugOverride: debugRelayOverride + ) currentEndpointRelayProfile = endpointRelayProfile + // A verified cached policy proves the broker has acknowledged + // this endpoint, so its relay dials pass the relay's allow hook + // immediately and the profile stays installed at bind. Without + // one the endpoint binds relay-less and the managed profile is + // installed only after registration is acknowledged below, + // ordering the first relay dial after broker admission (a denied + // pre-registration dial is negatively cached by the relay). + // Custom relays are user-operated and not admission-gated by the + // cmux broker, so they stay installed at bind. + let withholdsManagedRelaysUntilRegistered = + withholdsManagedRelaysUntilRegistered(for: endpointRelayProfile) let endpointConfiguration = CmxIrohEndpointConfiguration( secretKey: configuration.identity.secretKey, alpns: [protocolConfiguration.alpn], bindPolicy: configuration.bindPolicy, - relayProfile: endpointRelayProfile + relayProfile: withholdsManagedRelaysUntilRegistered + ? .unavailableManagedSelection + : endpointRelayProfile ) let connectivityEngine = CmxConnectivityEngine( factory: factory, @@ -208,12 +251,41 @@ public actor CmxIrohHostRuntime { throw CmxIrohHostRuntimeError.invalidLocalBinding } - let policy = try await resolveInitialPolicy( - engine: connectivityEngine, - expectedEndpointID: endpointID, - revision: revision - ) + let requiresRelayReadiness = !protocolConfiguration + .allowsNATTraversalAfterAdmission + // A verified same-binding cached policy activates admission and + // the endpoint immediately; the authenticated broker round then + // runs in the background and reconciles. First launch (no cache), + // an invalid cache, and relay-required debug hosts keep the + // blocking resolve. + let cachedStartPolicy = requiresRelayReadiness + ? nil + : validatedCachedStartPolicy(expectedEndpointID: endpointID) + let policy: ResolvedPolicy + if let cachedStartPolicy { + policy = cachedStartPolicy + } else { + policy = try await resolveInitialPolicy( + engine: connectivityEngine, + expectedEndpointID: endpointID, + revision: revision + ) + } try requireCurrent(revision) + if withholdsManagedRelaysUntilRegistered { + // The broker has now acknowledged this endpoint's binding: a + // fresh host cannot leave resolveInitialPolicy otherwise, + // because the cachedPolicy(after:) fallback requires the same + // verified cached policy whose absence made this bind + // withhold. Installing the managed relays only now guarantees + // the first relay dial cannot race the relay's allow hook + // into a negatively cached deny. + try await connectivityEngine.replaceRelayProfile( + endpointRelayProfile, + expectedIdentity: endpointID + ) + try requireCurrent(revision) + } let offlineSessions = CmxIrohOfflinePairingSessions( pairingEnabled: policy.pairingEnabled @@ -231,26 +303,9 @@ public actor CmxIrohHostRuntime { offlineSessions: offlineSessions, onlineRegistry: onlineAdmissionRegistry ) - let relayCoordinator: CmxIrohRelayCredentialCoordinator? - if endpointRelayProfile.source == .managed, - !endpointRelayProfile.allowedRelayURLs.isEmpty { - relayCoordinator = CmxIrohRelayCredentialCoordinator( - supervisor: connectivityEngine, - broker: broker, - managedRelayURLs: managedRelayURLs, - selectedRelayURLs: endpointRelayProfile.allowedRelayURLs, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, policy.binding) - } - ) - } else { - relayCoordinator = nil - } - self.offlineSessions = offlineSessions self.onlineAdmissionRegistry = onlineAdmissionRegistry self.admissionController = admissionController - self.relayCoordinator = relayCoordinator localBinding = policy.binding endpointAttestation = policy.attestation lanRendezvous = policy.lanRendezvous @@ -278,18 +333,7 @@ public actor CmxIrohHostRuntime { bindingID: policy.binding.bindingID ) var publishedPolicy = policy - let requiresRelayReadiness = !protocolConfiguration - .allowsNATTraversalAfterAdmission if requiresRelayReadiness { - if let relayCoordinator { - try await relayCoordinator.activate( - bindingID: policy.binding.bindingID, - endpointIdentity: endpointID, - bootstrap: policy.relayBootstrap, - waitForInitialCredential: true - ) - } - try requireCurrent(revision) guard await connectivityEngine.hasConfiguredRelay() else { throw CmxIrohEndpointSupervisorError.relayReadinessTimedOut } @@ -318,9 +362,18 @@ public actor CmxIrohHostRuntime { // into `readyPolicy`; do not immediately publish a third copy. registrationRefreshPending = false } + // Every path re-checks verified readiness immediately before + // publication. Relay-required activations arrive here only after + // the blocking waitForUsableHomeRelay() above succeeded, so the + // check returns true for them without a second wait. + let publishInline = await initialPublicationReady( + engine: connectivityEngine + ) + try requireCurrent(revision) let publishedFreshBinding: Bool if let registration = publishedPolicy.registration, - let discovery = publishedPolicy.discovery { + let discovery = publishedPolicy.discovery, + publishInline { await handleBinding(registration, discovery, publishedPolicy.attestation) try requireCurrent(revision) if let routeRevision = discovery.revision { @@ -337,36 +390,66 @@ public actor CmxIrohHostRuntime { } else { publishedFreshBinding = false } - await handleRoute( - publishedPolicy.binding, - publishedPolicy.routePathHints - ) - try requireCurrent(revision) + if publishedFreshBinding || publishedPolicy.registration == nil { + // Fresh relay-ready hints, or a cached authority whose local + // route identity is refreshed rather than unpublished. A live + // policy without a usable home relay publishes nothing yet: + // the Mac must not be discoverable-but-undialable. + await handleRoute( + publishedPolicy.binding, + publishedPolicy.routePathHints + ) + try requireCurrent(revision) + } registrationRefreshEnabled = true if !publishedFreshBinding { - // Cached authority keeps offline admission and LAN discovery - // available, but it cannot describe this endpoint generation's - // live direct port. Give the lifecycle-owned retry loop the - // incomplete activation so the broker is refreshed without - // creating a second endpoint or relying on another network event. registrationRefreshPending = false - scheduleRegistrationRetry( - revision: revision, - retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds - ) + // Every deferred first publication carries the pending flag so + // any refresh round that ends up performing it re-checks + // verified relay readiness first. + initialPublicationPending = true + if requiresRelayReadiness { + // Cached authority keeps offline admission and LAN + // discovery available, but it cannot describe this endpoint + // generation's live direct port. Give the lifecycle-owned + // retry loop the incomplete activation. + scheduleRegistrationRetry( + revision: revision, + retryAfterSeconds: publishedPolicy.registrationRetryAfterSeconds + ) + // The retry loop owns the next broker round, but only the + // ready gate observes a relay that becomes usable without + // a network-change event. Arm it here too so a pending + // first publication always has a relay-readiness owner; + // it defers to the armed retry round when one exists. + scheduleInitialPublication(revision: revision) + } else { + allowsReplacedBindingAdoption = cachedStartPolicy != nil + if let retryAfterSeconds = publishedPolicy + .registrationRetryAfterSeconds { + // A broker cooldown observed during activation keeps + // its validated floor for the next live round. + scheduleRegistrationRetry( + revision: revision, + retryAfterSeconds: retryAfterSeconds + ) + } else if cachedStartPolicy != nil { + // Cached authority is verified against the live broker + // immediately, independent of relay readiness, so a + // server-side revocation or replacement cannot hide + // behind a relay outage. Readiness gates only the + // publication inside the refresh round. + scheduleRegistrationRefresh(revision: revision) + } + // The ready gate waits for a usable home relay and then + // runs the round that performs the deferred first + // publication with fresh path hints. + scheduleInitialPublication(revision: revision) + } } else if registrationRefreshPending { registrationRefreshPending = false scheduleRegistrationRefresh(revision: revision) } - if let relayCoordinator, !requiresRelayReadiness { - scheduleRelayActivation( - relayCoordinator, - binding: policy.binding, - endpointID: endpointID, - bootstrap: policy.relayBootstrap, - revision: revision - ) - } scheduleLANPublication( binding: publishedPolicy.binding, rendezvous: publishedPolicy.lanRendezvous, @@ -557,49 +640,92 @@ public actor CmxIrohHostRuntime { await handleLANPolicy(context, directAddresses) } - func scheduleRelayActivation( - _ coordinator: CmxIrohRelayCredentialCoordinator, - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) { - relayActivationTask?.cancel() - relayActivationTask = Task { [weak self] in - await self?.activateRelaySidecar( - coordinator, - binding: binding, - endpointID: endpointID, - bootstrap: bootstrap, - revision: revision - ) + /// Returns whether the binding may be published immediately: the home + /// relay is already usable, or this endpoint will never own a relay. + /// + /// ROLLOUT NOTE (intended-shape attach reporting): this relay-readiness + /// gate and the post-attach republish it defers exist so the Mac's own + /// registration carries its relay route. The broker now also publishes + /// the route server-side from the relay fleet's attach/detach reports + /// (`POST /api/relay/report`, cmux-relay attach reporting), and + /// discovery serves that server-observed hint ahead of client-published + /// hints. The client republish stays as the fallback ONLY while fleet + /// relays that do not report attach remain deployed; once the reporting + /// relay build is rolled out fleet-wide, delete this gate and publish at + /// register time. + func initialPublicationReady( + engine: CmxConnectivityEngine + ) async -> Bool { + if await engine.hasUsableHomeRelay() { return true } + return !(await engine.hasConfiguredRelay()) + } + + func scheduleInitialPublication(revision: UInt64) { + initialPublicationTask?.cancel() + initialPublicationTask = Task { [weak self] in + await self?.runInitialPublication(revision: revision) } } - private func activateRelaySidecar( - _ coordinator: CmxIrohRelayCredentialCoordinator, - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) async { + private func runInitialPublication(revision: UInt64) async { guard lifecyclePhase == .active, lifecycleRevision == revision, - relayCoordinator === coordinator, !Task.isCancelled else { return } - do { - try await coordinator.activate( - bindingID: binding.bindingID, - endpointIdentity: endpointID, - bootstrap: bootstrap - ) - } catch { - // The coordinator owns bounded retry. A verified direct route stays - // authoritative when relay credential installation is unavailable. + guard let connectivityEngine else { return } + if await connectivityEngine.hasConfiguredRelay() { + // The Mac must never be discoverable-but-undialable: a readiness + // timeout keeps the endpoint unpublished and retries the wait with + // bounded backoff on the injected clock until a verified usable + // relay path exists or this lifecycle revision is superseded. + var readinessFailureCount = 0 + while true { + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + do { + try await connectivityEngine.waitForUsableHomeRelay( + timeout: relayReadinessTimeout + ) + break + } catch is CancellationError { + return + } catch CmxIrohEndpointSupervisorError.relayReadinessTimedOut { + // Retry below after bounded backoff. + } catch { + // The endpoint generation was replaced or deactivated. The + // successor lifecycle owns publication; this one stays + // unpublished and existing failure handling surfaces state. + return + } + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + let delay = registrationRetrySchedule.delay( + failureCount: readinessFailureCount, + retryAfterSeconds: nil, + jitterUnitInterval: registrationRetryJitter() + ) + readinessFailureCount = min(readinessFailureCount + 1, 20) + do { + try await registrationClock.sleep( + until: registrationClock.now().addingTimeInterval(delay) + ) + } catch { + return + } + } } - if relayCoordinator === coordinator { - relayActivationTask = nil + guard lifecyclePhase == .active, + lifecycleRevision == revision, + !Task.isCancelled else { return } + guard initialPublicationPending else { return } + guard registrationRefreshFailureCount == 0 else { + // A broker cooldown or failure retry is already armed. That + // lifecycle-owned round performs the deferred publication once it + // succeeds, and it honors the broker's validated retry floor. + return } + scheduleRegistrationRefresh(revision: revision) } func scheduleLANPublication( diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift index 9b16048a5e20..bd778a02dc93 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift @@ -21,8 +21,10 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { /// /// `nil` preserves automatic use of the complete managed fleet. public let endpointRelayProfile: CmxIrohEndpointRelayProfile? - public let cachedRelayCredential: CmxIrohRelayTokenResponse? - /// A previously verified offline policy considered only after broker connectivity failure. + /// A previously verified last-good policy. When it still verifies for this + /// exact binding it activates the host immediately (cache-first) while the + /// live broker resolve reconciles in the background; it also remains the + /// verified fallback after broker connectivity failure. public let cachedHostPolicy: CmxIrohCachedHostPolicy? /// Creates stable inputs for one Mac host runtime lifecycle. @@ -40,7 +42,6 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { /// - bindPolicy: The UDP bind behavior, ephemeral by default. /// - managedRelayURLs: The exact managed relay allowlist. /// - endpointRelayProfile: An optional local selection or custom override. - /// - cachedRelayCredential: A validated relay bootstrap for this endpoint. /// - cachedHostPolicy: A policy previously verified by ``CmxIrohHostPolicyCache``. public init( accountID: String, @@ -55,7 +56,6 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, managedRelayURLs: Set, endpointRelayProfile: CmxIrohEndpointRelayProfile? = nil, - cachedRelayCredential: CmxIrohRelayTokenResponse? = nil, cachedHostPolicy: CmxIrohCachedHostPolicy? = nil ) { self.accountID = accountID @@ -70,7 +70,16 @@ public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { self.bindPolicy = bindPolicy self.managedRelayURLs = managedRelayURLs self.endpointRelayProfile = endpointRelayProfile - self.cachedRelayCredential = cachedRelayCredential self.cachedHostPolicy = cachedHostPolicy } } + +extension CmxIrohHostRuntimeConfiguration { + func resolvedEndpointRelayProfile( + debugOverride: CmxIrohEndpointRelayProfile? = CmxIrohDebugRelayOverride.activeProfile() + ) throws -> CmxIrohEndpointRelayProfile { + if let debugOverride { return debugOverride } + return try endpointRelayProfile + ?? CmxIrohEndpointRelayProfile(managedRelayURLs: managedRelayURLs) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift deleted file mode 100644 index 350f413dd7e5..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// A peer-created unidirectional stream after its lane header is removed. -public struct CmxIrohInboundStream: Sendable { - /// The declared server-event or artifact lane. - public let lane: CmxIrohLane - - /// The readable application payload after the consumed header. - public let receiveStream: any CmxIrohReceiveStream - - /// Creates a decoded inbound stream. - /// - /// - Parameters: - /// - lane: The peer-declared application lane. - /// - receiveStream: The stream with any over-read bytes preserved. - public init(lane: CmxIrohLane, receiveStream: any CmxIrohReceiveStream) { - self.lane = lane - self.receiveStream = receiveStream - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift new file mode 100644 index 000000000000..366d12429106 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIncomingConnection.swift @@ -0,0 +1,22 @@ +/// One incoming connection attempt whose server-side handshake has not +/// completed yet. +/// +/// ``CmxIrohEndpoint/accept()`` returns this stage instead of a finished +/// connection so the accept loop's only job is draining the endpoint's accept +/// queue. The handshake is per-connection work owned by that connection's +/// admission task: a peer that stops making handshake progress (killed app, +/// dead relay path) can therefore never gate other peers' admissions. +public protocol CmxIrohIncomingConnection: Sendable { + /// Completes the server-side handshake and returns the connection. + /// + /// - Returns: The TLS-authenticated connection. + /// - Throws: A transport error when the handshake fails or the peer + /// negotiated an unexpected ALPN. + func establish() async throws -> any CmxIrohConnection + + /// Abandons the attempt without completing the handshake. + /// + /// Safe to call after ``establish()`` started; the attempt's resources are + /// released and an unfinished handshake is aborted by the driver. + func abandon() async +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift index cdc6867eb366..e749ede626d4 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift @@ -140,18 +140,13 @@ actor CmxIrohLibEndpoint: CmxIrohEndpoint { throw lastError ?? CmxIrohLibError.invalidEndpointIdentity } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { + // Only drain the accept queue here. The server-side handshake belongs + // to the returned attempt's establish(), owned by the per-connection + // admission task: one peer that dies after its Initial packet must not + // wedge the host's whole accept pipeline (the 2026-08-26 relay wedge). guard let incoming = await driver.acceptNext() else { return nil } - let accepting = try await incoming.accept() - guard alpns.contains(try await accepting.alpn()) else { - throw CmxIrohLibError.unexpectedALPN - } - return try CmxIrohLibConnection(driver: await accepting.connect()) - } - - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws { - let profile = try relayProfile.replacingManagedRelays(relays) - try await replaceRelayProfile(profile) + return CmxIrohLibIncomingConnection(incoming: incoming, alpns: alpns) } func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { @@ -163,14 +158,10 @@ actor CmxIrohLibEndpoint: CmxIrohEndpoint { let next = Dictionary( uniqueKeysWithValues: profile.activeRelays.map { ($0.url, $0) } ) - let now = Date() for relay in profile.activeRelays { guard profile.allowedRelayURLs.contains(relay.url) else { throw CmxIrohLibError.unmanagedRelayURL(relay.url) } - guard relay.isUsable(at: now) else { - throw CmxIrohLibError.expiredRelayCredential(relay.url) - } } let previous = relayConfigurations diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift index 4c1f6aff52e4..8ed0cd118224 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift @@ -47,11 +47,7 @@ public struct CmxIrohLibEndpointFactory: CmxIrohEndpointFactory { ) async throws -> Endpoint { let relayMap = RelayMap.empty() if transportVerificationMode != .directOnly { - let now = Date() for relay in configuration.relayProfile.activeRelays { - guard relay.isUsable(at: now) else { - throw CmxIrohLibError.expiredRelayCredential(relay.url) - } try relayMap.insert(config: CmxIrohLibEndpoint.relayConfig(relay)) } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift index 694ca4f48243..d581ed279e5b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift @@ -3,7 +3,6 @@ public enum CmxIrohLibError: Error, Equatable, Sendable { case invalidEndpointIdentity case remoteIdentityMismatch case unmanagedRelayURL(String) - case expiredRelayCredential(String) case unsupportedRelayIdentifier case unexpectedALPN case invalidReceiveLimit(Int) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift new file mode 100644 index 000000000000..85d937c1d5cc --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibIncomingConnection.swift @@ -0,0 +1,33 @@ +import Foundation +import IrohLib + +/// One un-handshaken incoming iroh connection attempt. +/// +/// ``establish()`` performs the server-side handshake (`Incoming.accept`, +/// ALPN validation, handshake completion) that used to run inline in the +/// endpoint's accept path. Once `accept()` consumes the `Incoming`, the +/// attempt cannot be aborted from Swift: `refuse()` reports "already +/// consumed" and the bindings do not propagate task cancellation into the +/// driver. The attempt therefore resolves only when the driver's own +/// handshake/idle timeout bounds it, and ``CmxIrohEndpointServer`` keeps +/// the admission slot occupied until that resolution, so a stalled attempt +/// can only ever cost its own admission slot. +struct CmxIrohLibIncomingConnection: CmxIrohIncomingConnection { + let incoming: Incoming + let alpns: Set + + func establish() async throws -> any CmxIrohConnection { + let accepting = try await incoming.accept() + guard alpns.contains(try await accepting.alpn()) else { + throw CmxIrohLibError.unexpectedALPN + } + return try CmxIrohLibConnection(driver: await accepting.connect()) + } + + func abandon() async { + // Refuse an unconsumed attempt so the dialer fails fast. An attempt + // whose establish() already consumed the Incoming reports "already + // consumed"; dropping the in-flight Accepting aborts that handshake. + try? await incoming.refuse() + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift deleted file mode 100644 index d45520df91dc..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift +++ /dev/null @@ -1,55 +0,0 @@ -/// One broker-issued credential associated with one exact managed relay URL. -public struct CmxIrohManagedRelayCredential: Codable, Equatable, Sendable, - CustomStringConvertible, CustomDebugStringConvertible -{ - /// The exact canonical relay URL covered by this credential. - public let relayURL: String - - /// The opaque relay authentication token. - public let token: String - - /// The provider-enforced expiry in ISO 8601 format. - public let expiresAt: String - - /// The replacement time in ISO 8601 format. - public let refreshAfter: String - - /// Creates one URL-bound managed relay credential. - /// - /// Structural and lifetime validation is centralized in - /// ``CmxIrohRelayTokenResponse/relayConfigurations(now:)`` so network and - /// restored credentials follow the same validation path. - /// - /// - Parameters: - /// - relayURL: The exact managed relay URL covered by the token. - /// - token: The provider-issued opaque relay token. - /// - expiresAt: The provider-enforced expiry in ISO 8601 format. - /// - refreshAfter: The replacement time in ISO 8601 format. - public init( - relayURL: String, - token: String, - expiresAt: String, - refreshAfter: String - ) { - self.relayURL = relayURL - self.token = token - self.expiresAt = expiresAt - self.refreshAfter = refreshAfter - } - - /// A log-safe representation that never includes the opaque token. - public var description: String { - "CmxIrohManagedRelayCredential(relayURL: \(relayURL), token: , " - + "expiresAt: \(expiresAt), refreshAfter: \(refreshAfter))" - } - - /// A debug representation that never includes the opaque token. - public var debugDescription: String { description } - - private enum CodingKeys: String, CodingKey { - case relayURL = "relay_url" - case token - case expiresAt = "expires_at" - case refreshAfter = "refresh_after" - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift index a5fb93af343a..527934d61d6a 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift @@ -193,6 +193,35 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { ) usedFreshDiscovery = true } catch { + try Task.checkCancellation() + // The refresh failed. For the transient class (connectivity, + // broker cooldown, availability blips) dialing with the last + // verified snapshot beats not dialing at all (cmux#9724): the + // staleness mark survives, so the next attempt still + // refetches once the broker recovers. Every other failure is + // a trust signal (rollback/equivocation detection, a + // non-transient rejection, invalid authentication, a + // malformed authority response) and fails closed toward + // re-discovery instead of being masked by stale identity + // data. + if CmxIrohTrustBrokerClientError + .preservesVerifiedStateDuringRefresh(error), + let lastGood = authoritativeDiscovery { + do { + return try await resolveContext( + for: request, + targetIdentity: targetIdentity, + routeHints: routeHints, + discovery: lastGood, + at: clock + ) + } catch is CancellationError { + throw CancellationError() + } catch { + // The last-good snapshot no longer authorizes this + // peer; fall through to the offline cache. + } + } guard Self.isConnectivity(error), let cached = try await cachedPolicy( for: request, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift deleted file mode 100644 index c7f274a5a67b..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Relay credential and signed policy returned by one bootstrap request. -public struct CmxIrohRelayBootstrapResponse: Equatable, Sendable { - /// Managed relay credential, absent for custom or direct-only preferences. - public let relayToken: CmxIrohRelayTokenResponse? - - /// Signed policy and account preference resolved by the broker. - public let relayPolicy: CmxIrohRelayPolicyResponse - - /// Creates one validated bootstrap response. - public init( - relayToken: CmxIrohRelayTokenResponse?, - relayPolicy: CmxIrohRelayPolicyResponse - ) { - self.relayToken = relayToken - self.relayPolicy = relayPolicy - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift deleted file mode 100644 index 0ec058c4a124..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift +++ /dev/null @@ -1,87 +0,0 @@ -public import Foundation - -/// A short-lived endpoint-scoped credential for one managed relay. -public struct CmxIrohRelayConfiguration: Equatable, Sendable { - /// The exact canonical relay URL accepted by the app configuration. - public let url: String - - /// The compact JWT, or pre-migration RCAN, used as Iroh's relay auth token. - public let token: String - - /// The hard time after which the relay must reject the token. - public let expiresAt: Date - - /// The time at which cmux should obtain a replacement before expiry. - public let refreshAfter: Date - - /// Creates a validated managed-relay configuration. - /// - /// - Parameters: - /// - url: A canonical HTTPS relay origin with a trailing slash. - /// - token: A compact Base64URL JWT or legacy lowercase Base32 RCAN. - /// - expiresAt: The provider-enforced token expiry. - /// - refreshAfter: A replacement time strictly before expiry. - /// - now: The validation time, injected for deterministic tests. - /// - Throws: ``CmxIrohRelayConfigurationError`` for malformed or expired input. - public init( - url: String, - token: String, - expiresAt: Date, - refreshAfter: Date, - now: Date - ) throws { - guard Self.isCanonicalRelayURL(url) else { - throw CmxIrohRelayConfigurationError.invalidURL - } - guard (1 ... 8 * 1_024).contains(token.utf8.count), - Self.isCompactJWT(token) || Self.isLegacyRCAN(token) else { - throw CmxIrohRelayConfigurationError.invalidToken - } - guard now < refreshAfter, refreshAfter < expiresAt else { - throw CmxIrohRelayConfigurationError.invalidLifetime - } - self.url = url - self.token = token - self.expiresAt = expiresAt - self.refreshAfter = refreshAfter - } - - private static func isBase64URLByte(_ byte: UInt8) -> Bool { - (UInt8(ascii: "a") ... UInt8(ascii: "z")).contains(byte) - || (UInt8(ascii: "A") ... UInt8(ascii: "Z")).contains(byte) - || (UInt8(ascii: "0") ... UInt8(ascii: "9")).contains(byte) - || byte == UInt8(ascii: "-") - || byte == UInt8(ascii: "_") - } - - private static func isCompactJWT(_ value: String) -> Bool { - let segments = value.split(separator: ".", omittingEmptySubsequences: false) - return segments.count == 3 && segments.allSatisfy { segment in - !segment.isEmpty && segment.utf8.allSatisfy(Self.isBase64URLByte) - } - } - - private static func isLegacyRCAN(_ value: String) -> Bool { - value.utf8.allSatisfy { byte in - (UInt8(ascii: "a") ... UInt8(ascii: "z")).contains(byte) - || (UInt8(ascii: "2") ... UInt8(ascii: "7")).contains(byte) - } - } - - private static func isCanonicalRelayURL(_ value: String) -> Bool { - guard let components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.port == nil, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path == "/" else { - return false - } - return components.string == value - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift deleted file mode 100644 index 8c40f7bad709..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// Validation failures for a managed Iroh relay credential. -public enum CmxIrohRelayConfigurationError: Error, Equatable, Sendable { - /// The relay URL is not a canonical HTTPS origin ending in `/`. - case invalidURL - - /// The RCAN token is empty, too large, or not lowercase unpadded Base32. - case invalidToken - - /// The token expiry or refresh schedule is already invalid when decoded. - case invalidLifetime -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift deleted file mode 100644 index a2fad66b2758..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift +++ /dev/null @@ -1,632 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Keeps endpoint-scoped relay credentials fresh without recreating the endpoint. -public actor CmxIrohRelayCredentialCoordinator { - private static let minimumUsefulValidity: TimeInterval = 10 - private static let postExpiryRetryDelay: TimeInterval = 1 - - private struct Binding: Equatable, Sendable { - let id: String - let endpointIdentity: CmxIrohPeerIdentity - } - - private struct InstalledCredential: Equatable, Sendable { - let refreshAfter: Date - let expiresAt: Date - } - - private struct PendingPersistence: Sendable { - let response: CmxIrohRelayTokenResponse - let binding: Binding - let revision: UInt64 - } - - private struct InFlightRefresh { - let id: UUID - let task: Task - } - - private let supervisor: any CmxIrohRelayEndpointControlling - private let broker: any CmxIrohRelayTokenServing - private let managedRelayURLs: Set - private let selectedRelayURLs: Set - private let clock: any CmxIrohRelayClock - private let jitter: @Sendable (_ now: Date, _ refreshAfter: Date) -> Date - private let retrySchedule: CmxIrohRetrySchedule - private let retryJitter: @Sendable () -> Double - private let automaticRefreshEnabled: Bool - private let credentialDidInstall: @Sendable (CmxIrohRelayTokenResponse) async -> Void - private var binding: Binding? - private var installedCredential: InstalledCredential? - private var lifecycleRevision: UInt64 = 0 - private var refreshTask: Task? - private var inFlightRefresh: InFlightRefresh? - private var persistenceTask: Task? - private var pendingPersistence: PendingPersistence? - - /// Creates an inactive relay credential coordinator. - public init( - supervisor: any CmxIrohRelayEndpointControlling, - broker: any CmxIrohRelayTokenServing, - managedRelayURLs: Set, - selectedRelayURLs: Set? = nil, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), - jitter: @escaping @Sendable (_ now: Date, _ refreshAfter: Date) -> Date = { - now, - refreshAfter in - let window = min(30, max(0, refreshAfter.timeIntervalSince(now))) - return refreshAfter.addingTimeInterval(-Double.random(in: 0 ... window)) - }, - retrySchedule: CmxIrohRetrySchedule = CmxIrohRetrySchedule(), - retryJitter: @escaping @Sendable () -> Double = { - Double.random(in: 0 ... 1) - }, - automaticRefreshEnabled: Bool = true, - credentialDidInstall: @escaping @Sendable ( - CmxIrohRelayTokenResponse - ) async -> Void = { _ in } - ) { - self.supervisor = supervisor - self.broker = broker - self.managedRelayURLs = managedRelayURLs - self.selectedRelayURLs = selectedRelayURLs ?? managedRelayURLs - self.clock = clock - self.jitter = jitter - self.retrySchedule = retrySchedule - self.retryJitter = retryJitter - self.automaticRefreshEnabled = automaticRefreshEnabled - self.credentialDidInstall = credentialDidInstall - } - - /// Starts refresh scheduling for one exact registered endpoint binding. - /// - /// A bootstrap credential is installed before scheduling. Bootstrap - /// validation failure is returned while an immediate broker retry is - /// scheduled by default. Relay-required callers instead wait through the - /// same bounded-backoff schedule until one credential installs or activation - /// is cancelled. - public func activate( - bindingID: String, - endpointIdentity: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse? = nil, - waitForInitialCredential: Bool = false - ) async throws { - let (expectedBinding, revision) = beginActivation( - bindingID: bindingID, - endpointIdentity: endpointIdentity - ) - - if let bootstrap { - do { - let installed = try await install( - bootstrap, - binding: expectedBinding, - revision: revision - ) - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - return - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - if waitForInitialCredential { - try await installInitialCredentialAfterRetry( - binding: expectedBinding, - revision: revision, - firstRetry: nil, - initialFailureCount: 0 - ) - } else { - startLoopIfEnabled(revision: revision, firstRefresh: nil) - throw error - } - return - } - } - do { - let response = try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointIdentity - ) - let installed = try await install( - response, - binding: expectedBinding, - revision: revision - ) - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - let delay = retryDelay(failureCount: 0, error: error) - let firstRetry = retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ) - if waitForInitialCredential { - try await installInitialCredentialAfterRetry( - binding: expectedBinding, - revision: revision, - firstRetry: firstRetry, - initialFailureCount: 1 - ) - } else { - startLoopIfEnabled( - revision: revision, - firstRefresh: firstRetry, - initialFailureCount: 1 - ) - } - } - } - - /// Replaces one live managed relay policy and starts credential refresh. - /// - /// The coordinator owns the endpoint mutation so a policy bootstrap is - /// installed exactly once. This preserves active QUIC sessions while the - /// endpoint's relay client adopts the replacement credentials. - /// - /// - Parameters: - /// - bindingID: The broker binding that owns the endpoint. - /// - endpointIdentity: The pinned endpoint identity being updated. - /// - profile: The complete managed relay profile to install. - /// - bootstrap: Credentials already represented by `profile`, when available. - /// - Throws: A policy mismatch, endpoint mutation failure, or cancellation. - public func activateManagedPolicy( - bindingID: String, - endpointIdentity: CmxIrohPeerIdentity, - profile: CmxIrohEndpointRelayProfile, - bootstrap: CmxIrohRelayTokenResponse? - ) async throws { - guard profile.source == .managed, - !selectedRelayURLs.isEmpty, - selectedRelayURLs.isSubset(of: managedRelayURLs), - profile.allowedRelayURLs == selectedRelayURLs else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - - let bootstrapInstallation: ( - response: CmxIrohRelayTokenResponse, - configurations: [CmxIrohRelayConfiguration] - )? = try bootstrap.map { response in - let selectedConfigurations = try validatedSelectedConfigurations(response) - guard profile.managedRelays.count == selectedConfigurations.count, - profile.managedRelays.allSatisfy(selectedConfigurations.contains) else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - return (response, selectedConfigurations) - } - - let (expectedBinding, revision) = beginActivation( - bindingID: bindingID, - endpointIdentity: endpointIdentity - ) - try await supervisor.replaceRelayProfile( - profile, - expectedIdentity: endpointIdentity - ) - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding else { - throw CancellationError() - } - - if let bootstrapInstallation { - let installed = try recordInstallation( - bootstrapInstallation.response, - selectedConfigurations: bootstrapInstallation.configurations, - binding: expectedBinding, - revision: revision - ) - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - return - } - - do { - let response = try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointIdentity - ) - let installed = try await install( - response, - binding: expectedBinding, - revision: revision - ) - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - let delay = retryDelay(failureCount: 0, error: error) - startLoopIfEnabled( - revision: revision, - firstRefresh: retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ), - initialFailureCount: 1 - ) - } - } - - private func beginActivation( - bindingID: String, - endpointIdentity: CmxIrohPeerIdentity - ) -> (Binding, UInt64) { - lifecycleRevision &+= 1 - let revision = lifecycleRevision - refreshTask?.cancel() - inFlightRefresh?.task.cancel() - inFlightRefresh = nil - let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity) - binding = expectedBinding - installedCredential = nil - return (expectedBinding, revision) - } - - private func installInitialCredentialAfterRetry( - binding: Binding, - revision: UInt64, - firstRetry: Date?, - initialFailureCount: Int - ) async throws { - var deadline = firstRetry - var failureCount = initialFailureCount - while isCurrent(revision), !Task.isCancelled { - if let deadline { - try await clock.sleep(until: deadline) - } - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - do { - let installed = try await refreshCredential( - binding: binding, - revision: revision - ) - startLoopIfEnabled( - revision: revision, - firstRefresh: installed.refreshAfter - ) - return - } catch is CancellationError { - throw CancellationError() - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - let delay = retryDelay(failureCount: failureCount, error: error) - deadline = retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ) - failureCount = min(failureCount + 1, 20) - } - } - throw CancellationError() - } - - /// Cancels all scheduled refresh work and forgets binding-scoped state. - public func deactivate() { - lifecycleRevision &+= 1 - refreshTask?.cancel() - refreshTask = nil - inFlightRefresh?.task.cancel() - inFlightRefresh = nil - persistenceTask?.cancel() - persistenceTask = nil - pendingPersistence = nil - binding = nil - installedCredential = nil - } - - /// Returns the hard expiry of the last successfully installed credential. - public func credentialExpiresAt() -> Date? { - installedCredential?.expiresAt - } - - /// Immediately catches up a missing or refresh-due relay credential. - /// - /// iOS suspends task scheduling in the background, so the ordinary sleep - /// loop may not run before an installed credential expires. Foreground - /// connection readiness calls this method before dialing. Concurrent - /// callers share one mint-and-install operation, and a failure preserves - /// the existing endpoint while resuming the bounded retry loop. - public func refreshIfNeeded() async throws { - guard let binding else { - throw CmxIrohRelayCredentialCoordinatorError.inactive - } - guard automaticRefreshEnabled else { return } - let now = clock.now() - if let installedCredential, - now < installedCredential.refreshAfter, - installedCredential.expiresAt.timeIntervalSince(now) - > Self.minimumUsefulValidity { - return - } - let revision = lifecycleRevision - do { - let installed = try await refreshCredential( - binding: binding, - revision: revision - ) - refreshTask?.cancel() - startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter) - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - refreshTask?.cancel() - let delay = retryDelay(failureCount: 0, error: error) - startLoopIfEnabled( - revision: revision, - firstRefresh: retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ), - initialFailureCount: 1 - ) - throw error - } - } - - private func startLoopIfEnabled( - revision: UInt64, - firstRefresh: Date?, - initialFailureCount: Int = 0 - ) { - guard automaticRefreshEnabled else { return } - refreshTask = Task { [weak self] in - await self?.run( - revision: revision, - firstRefresh: firstRefresh, - initialFailureCount: initialFailureCount - ) - } - } - - private func run( - revision: UInt64, - firstRefresh: Date?, - initialFailureCount: Int - ) async { - var deadline = firstRefresh - var failureCount = initialFailureCount - while isCurrent(revision) { - if let deadline { - do { - try await clock.sleep(until: deadline) - } catch { - return - } - } - guard isCurrent(revision), !Task.isCancelled, let binding else { return } - do { - let installed = try await refreshCredential( - binding: binding, - revision: revision - ) - failureCount = 0 - deadline = installed.refreshAfter - } catch is CancellationError { - return - } catch { - guard isCurrent(revision), !Task.isCancelled else { return } - let now = clock.now() - let delay = retryDelay(failureCount: failureCount, error: error) - deadline = retryDeadline( - now: now, - backoff: delay, - honorsServerFloor: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds != nil - ) - failureCount = min(failureCount + 1, 20) - } - } - } - - private func refreshCredential( - binding: Binding, - revision: UInt64 - ) async throws -> InstalledCredential { - if let inFlightRefresh { - return try await inFlightRefresh.task.value - } - let refreshID = UUID() - let task = Task { [weak self] in - guard let self else { throw CancellationError() } - let response = try await self.broker.issueRelayToken( - bindingID: binding.id, - endpointID: binding.endpointIdentity - ) - return try await self.install( - response, - binding: binding, - revision: revision - ) - } - inFlightRefresh = InFlightRefresh(id: refreshID, task: task) - do { - let installed = try await task.value - clearInFlightRefresh(id: refreshID) - return installed - } catch { - clearInFlightRefresh(id: refreshID) - throw error - } - } - - private func clearInFlightRefresh(id: UUID) { - guard inFlightRefresh?.id == id else { return } - inFlightRefresh = nil - } - - /// Keeps refresh retries inside the useful lifetime of an installed token. - /// - /// Exponential backoff alone can place the first retry at expiry because - /// five-minute relay tokens refresh only one minute early. Halving the - /// remaining lifetime preserves multiple bounded attempts. Once too little - /// validity remains for a useful mint-and-install round trip, retry just - /// after expiry and reset the backoff instead of growing a long outage. - private func retryDeadline( - now: Date, - backoff: TimeInterval, - honorsServerFloor: Bool - ) -> Date { - if honorsServerFloor { - return now.addingTimeInterval(backoff) - } - guard let expiresAt = installedCredential?.expiresAt, - now < expiresAt else { - return now.addingTimeInterval(backoff) - } - let remainingValidity = expiresAt.timeIntervalSince(now) - guard remainingValidity > Self.minimumUsefulValidity else { - return expiresAt.addingTimeInterval(Self.postExpiryRetryDelay) - } - return min( - now.addingTimeInterval(backoff), - now.addingTimeInterval(remainingValidity / 2) - ) - } - - private func retryDelay(failureCount: Int, error: any Error) -> TimeInterval { - retrySchedule.delay( - failureCount: failureCount, - retryAfterSeconds: (error as? any CmxRetryAfterProviding)? - .retryAfterSeconds, - jitterUnitInterval: retryJitter() - ) - } - - private func install( - _ response: CmxIrohRelayTokenResponse, - binding expectedBinding: Binding, - revision: UInt64 - ) async throws -> InstalledCredential { - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding else { - throw CancellationError() - } - let selectedConfigurations = try validatedSelectedConfigurations(response) - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding else { - throw CancellationError() - } - if selectedRelayURLs == managedRelayURLs { - try await supervisor.replaceRelays( - selectedConfigurations, - expectedIdentity: expectedBinding.endpointIdentity - ) - } else { - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: selectedRelayURLs, - relays: selectedConfigurations - ) - try await supervisor.replaceRelayProfile( - profile, - expectedIdentity: expectedBinding.endpointIdentity - ) - } - return try recordInstallation( - response, - selectedConfigurations: selectedConfigurations, - binding: expectedBinding, - revision: revision - ) - } - - private func validatedSelectedConfigurations( - _ response: CmxIrohRelayTokenResponse - ) throws -> [CmxIrohRelayConfiguration] { - guard response.relayFleet.count == managedRelayURLs.count, - Set(response.relayFleet) == managedRelayURLs else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - let configurations = try response.relayConfigurations(now: clock.now()) - let selectedConfigurations = configurations.filter { - selectedRelayURLs.contains($0.url) - } - guard !selectedRelayURLs.isEmpty, - selectedConfigurations.count == selectedRelayURLs.count, - selectedRelayURLs.isSubset(of: managedRelayURLs) else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - return selectedConfigurations - } - - private func recordInstallation( - _ response: CmxIrohRelayTokenResponse, - selectedConfigurations: [CmxIrohRelayConfiguration], - binding expectedBinding: Binding, - revision: UInt64 - ) throws -> InstalledCredential { - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding, - let refreshAfter = selectedConfigurations.map(\.refreshAfter).min(), - let expiresAt = selectedConfigurations.map(\.expiresAt).min() else { - throw CancellationError() - } - let installed = InstalledCredential( - refreshAfter: scheduledRefresh(refreshAfter), - expiresAt: expiresAt - ) - installedCredential = installed - enqueuePersistence( - response: response, - binding: expectedBinding, - revision: revision - ) - return installed - } - - /// Persists only the newest installed credential on one cancellable serial lane. - /// Runtime installation and refresh scheduling never await secure storage. - private func enqueuePersistence( - response: CmxIrohRelayTokenResponse, - binding: Binding, - revision: UInt64 - ) { - pendingPersistence = PendingPersistence( - response: response, - binding: binding, - revision: revision - ) - guard persistenceTask == nil else { return } - persistenceTask = Task { [weak self] in - await self?.runPersistenceQueue() - } - } - - private func runPersistenceQueue() async { - while !Task.isCancelled, let next = pendingPersistence { - pendingPersistence = nil - guard isCurrent(next.revision), binding == next.binding else { continue } - await credentialDidInstall(next.response) - } - persistenceTask = nil - if pendingPersistence != nil, !Task.isCancelled { - persistenceTask = Task { [weak self] in - await self?.runPersistenceQueue() - } - } - } - - private func scheduledRefresh(_ refreshAfter: Date) -> Date { - let now = clock.now() - let candidate = jitter(now, refreshAfter) - return min(refreshAfter, max(now, candidate)) - } - - private func isCurrent(_ revision: UInt64) -> Bool { - lifecycleRevision == revision - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift deleted file mode 100644 index cdc52cc72f47..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Relay credential scheduling failures owned by the app transport layer. -public enum CmxIrohRelayCredentialCoordinatorError: Error, Equatable, Sendable { - case inactive - case relayFleetMismatch -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift index f396b8bd02e3..8cea8ceac3cf 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift @@ -32,6 +32,66 @@ public struct CmxIrohRelayDiagnosticsSnapshot: Equatable, Sendable { /// Last non-secret policy resolution failure. public let failure: CmxIrohRelayPolicyFailure? + /// Start of the current run of consecutive policy refresh failures, `nil` + /// while the last refresh succeeded. A host whose refresh keeps failing + /// must be visibly degraded instead of silently unreachable + /// (cmux#10873); consumers treat the state as persistent once + /// ``consecutiveRefreshFailures`` reaches + /// ``CmxIrohRelayPolicyService/persistentRefreshFailureThreshold``. + public let refreshFailingSince: Date? + + /// Length of the current run of consecutive policy refresh failures. + public let consecutiveRefreshFailures: Int + + init( + source: CmxIrohRelayPolicySource, + policyID: String?, + policySequence: Int64?, + policyExpiresAt: Date?, + preferenceRevision: Int64?, + selectedRelayIDs: [String], + selectedRelayCount: Int, + staleRelayIDs: [String], + missingCredentialRelayIDs: [String], + failure: CmxIrohRelayPolicyFailure?, + refreshFailingSince: Date? = nil, + consecutiveRefreshFailures: Int = 0 + ) { + self.source = source + self.policyID = policyID + self.policySequence = policySequence + self.policyExpiresAt = policyExpiresAt + self.preferenceRevision = preferenceRevision + self.selectedRelayIDs = selectedRelayIDs + self.selectedRelayCount = selectedRelayCount + self.staleRelayIDs = staleRelayIDs + self.missingCredentialRelayIDs = missingCredentialRelayIDs + self.failure = failure + self.refreshFailingSince = refreshFailingSince + self.consecutiveRefreshFailures = consecutiveRefreshFailures + } + + /// The same snapshot restamped with the current refresh failure streak. + func withRefreshFailureStreak( + since: Date?, + count: Int + ) -> CmxIrohRelayDiagnosticsSnapshot { + CmxIrohRelayDiagnosticsSnapshot( + source: source, + policyID: policyID, + policySequence: policySequence, + policyExpiresAt: policyExpiresAt, + preferenceRevision: preferenceRevision, + selectedRelayIDs: selectedRelayIDs, + selectedRelayCount: selectedRelayCount, + staleRelayIDs: staleRelayIDs, + missingCredentialRelayIDs: missingCredentialRelayIDs, + failure: failure, + refreshFailingSince: since, + consecutiveRefreshFailures: count + ) + } + static let inactive = CmxIrohRelayDiagnosticsSnapshot( source: .inactive, policyID: nil, diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayEndpointControlling.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayEndpointControlling.swift deleted file mode 100644 index ff893afe79de..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayEndpointControlling.swift +++ /dev/null @@ -1,18 +0,0 @@ -public import CMUXMobileCore - -/// Endpoint relay mutations required by the credential coordinator. -public protocol CmxIrohRelayEndpointControlling: Sendable { - /// Replaces managed relay credentials on the expected endpoint identity. - func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity - ) async throws - - /// Replaces the complete relay profile on the expected endpoint identity. - func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile, - expectedIdentity: CmxIrohPeerIdentity - ) async throws -} - -extension CmxIrohEndpointSupervisor: CmxIrohRelayEndpointControlling {} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift index 6712e257bc9d..e27d5105ae99 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift @@ -92,21 +92,66 @@ public actor CmxIrohRelayPolicyCache { return policy } + /// Hard upper bound on the expired-policy reuse window accepted by + /// ``load(trustRoot:now:expiredPolicyReuseGrace:)``. The grace exists to + /// ride out a failed refresh, not to make an expired signed policy + /// reusable indefinitely; the cache is the single authority on how far + /// past its signed expiry a record may still load, so any larger or + /// non-finite caller value is clamped here. + public static let maximumExpiredPolicyReuseGrace: TimeInterval = 24 * 60 * 60 + /// Loads and re-verifies the cached policy at the current time. /// /// - Parameters: /// - trustRoot: App-pinned public verification keys. /// - now: Verification time. + /// - expiredPolicyReuseGrace: Bounded fail-open window after the signed + /// expiry in which the last-good policy still loads, clamped to + /// ``maximumExpiredPolicyReuseGrace``. The policy is re-verified at + /// its final valid instant, so signature, rollback, and claim checks + /// run unweakened; only the expiry gate is graced (cmux#10375). Zero + /// preserves strict expiry. /// - Returns: The verified policy, or `nil` when no policy is cached. /// - Throws: ``CmxIrohRelayPolicyError`` or a secure-storage error. public func load( trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date + now: Date, + expiredPolicyReuseGrace: TimeInterval = 0 ) async throws -> CmxIrohManagedRelayPolicy? { await acquire() defer { release() } guard let record = try await storedRecord() else { return nil } - let policy = try verifier.verify(record.signedPolicy, trustRoot: trustRoot, now: now) + let policy: CmxIrohManagedRelayPolicy + do { + policy = try verifier.verify( + record.signedPolicy, + trustRoot: trustRoot, + now: now + ) + } catch CmxIrohRelayPolicyError.expired { + // The recorded expiry is cross-checked against the signed claims + // below; a record that overstates it re-fails as expired here or + // as rollback below. A NaN grace fails the positivity gate, so + // non-finite caller values degrade to strict expiry or the clamp. + let grace = min( + expiredPolicyReuseGrace, + Self.maximumExpiredPolicyReuseGrace + ) + guard grace > 0, + let recordedExpiry = record.expiresAt, + now.timeIntervalSince1970 + <= TimeInterval(recordedExpiry) + grace else { + throw CmxIrohRelayPolicyError.expired + } + let lastValidInstant = Date( + timeIntervalSince1970: TimeInterval(recordedExpiry) - 1 + ) + policy = try verifier.verify( + record.signedPolicy, + trustRoot: trustRoot, + now: min(now, lastValidInstant) + ) + } guard policy.sequence == record.highestSequence, Self.metadataMatches(policy, record: record) else { throw CmxIrohRelayPolicyError.rollback diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift index c73aa1da6803..2560e0ccc89c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift @@ -26,7 +26,4 @@ public enum CmxIrohRelayPolicyFailure: String, Codable, Equatable, Sendable { /// The server committed an account change that this device could not cache. case preferencePersistenceUnavailable - - /// The signed managed allowlist is active without a usable current token. - case managedCredentialUnavailable } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift index 662489f42d4d..29642b03a238 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift @@ -13,7 +13,6 @@ enum CmxIrohRelayPolicyResolution { configuration: CmxIrohAccountRelayConfiguration, revision: Int64, policy: CmxIrohManagedRelayPolicy?, - relayCredential: CmxIrohRelayTokenResponse?, accountID: String, credentialStore: CmxIrohCustomRelayCredentialStore, usedCachedPolicy: Bool, @@ -35,11 +34,9 @@ enum CmxIrohRelayPolicyResolution { requestedConfiguration: configuration, effectivePreference: .automatic, policy: policy, - credential: relayCredential, staleRelayIDs: [], revision: revision, - usedCachedPolicy: usedCachedPolicy, - now: now + usedCachedPolicy: usedCachedPolicy ) case let .managed(requestedIDs): guard let policy else { @@ -69,11 +66,9 @@ enum CmxIrohRelayPolicyResolution { requestedConfiguration: configuration, effectivePreference: .managed(surviving), policy: policy, - credential: relayCredential, staleRelayIDs: stale, revision: revision, - usedCachedPolicy: usedCachedPolicy, - now: now + usedCachedPolicy: usedCachedPolicy ) case let .custom(definitions): let tokens: [String: String] @@ -154,30 +149,13 @@ enum CmxIrohRelayPolicyResolution { requestedConfiguration: CmxIrohAccountRelayConfiguration, effectivePreference: CmxIrohAccountRelayPreference, policy: CmxIrohManagedRelayPolicy, - credential: CmxIrohRelayTokenResponse?, staleRelayIDs: Set, revision: Int64, - usedCachedPolicy: Bool, - now: Date + usedCachedPolicy: Bool ) -> Resolution { do { let snapshot = try CmxIrohRelayPolicySnapshot(policy: policy, selection: selection) - var selectedCredentials: [CmxIrohRelayConfiguration] = [] - var failure: CmxIrohRelayPolicyFailure? - var relayBootstrap: CmxIrohRelayTokenResponse? - if let credential, - Set(credential.relayFleet) == Set(policy.relays.map(\.url)), - credential.relayFleet.count == policy.relays.count, - let configurations = try? credential.relayConfigurations(now: now) { - selectedCredentials = configurations.filter { snapshot.relayURLs.contains($0.url) } - relayBootstrap = credential - } else { - failure = .managedCredentialUnavailable - } - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: snapshot.relayURLs, - relays: selectedCredentials - ) + let profile = try CmxIrohEndpointRelayProfile(snapshot: snapshot) return Resolution( effective: CmxIrohEffectiveRelayPolicy( endpointRelayProfile: profile, @@ -188,10 +166,9 @@ enum CmxIrohRelayPolicyResolution { staleRelayIDs: staleRelayIDs, source: .managed, usedCachedPolicy: usedCachedPolicy, - preferenceRevision: revision, - relayBootstrap: relayBootstrap + preferenceRevision: revision ), - failure: failure + failure: nil ) } catch { return unavailableResolution( diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift index 8405a2292b6c..0a5a9a71388c 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift @@ -6,75 +6,84 @@ public actor CmxIrohRelayPolicyService { private typealias Resolution = CmxIrohRelayPolicyResolutionResult private typealias Resolver = CmxIrohRelayPolicyResolution + /// Bounded fail-open window in which ``restore`` keeps a recently-expired + /// last-good managed policy dialable after a failed refresh (cmux#10375). + /// A failed policy refresh must degrade to the last verified catalog, not + /// to a zero-route profile; the relay itself remains the authority on + /// credential validity and rejects a truly stale token. + public static let defaultExpiredPolicyReuseGrace: TimeInterval = 6 * 60 * 60 + + /// Consecutive ``refresh(accountID:trustRoot:now:)`` failures after which + /// the failure streak counts as persistent and must surface as a visible + /// degraded host state (cmux#10873). Below this, transient broker or + /// network hiccups stay quiet because the retry loop is already armed. + public static let persistentRefreshFailureThreshold = 3 + private let policyCache: CmxIrohRelayPolicyCache private let preferenceStore: CmxIrohRelayPreferenceStore private let credentialStore: CmxIrohCustomRelayCredentialStore private let broker: (any CmxIrohRelayPolicyServing)? + private let expiredPolicyReuseGrace: TimeInterval private var currentEffective: CmxIrohEffectiveRelayPolicy? private var currentDiagnostics = CmxIrohRelayDiagnosticsSnapshot.inactive private var continuations: [UUID: AsyncStream.Continuation] = [:] private var operationRevision: UInt64 = 0 + /// Current run of consecutive broker refresh failures; `nil` while the + /// last refresh succeeded. Stamped onto every published diagnostics + /// snapshot so a persistently failing refresh is visible host state. + private var refreshFailingSince: Date? + private var consecutiveRefreshFailures = 0 /// Creates an inactive relay policy service with injected persistence boundaries. public init( policyCache: CmxIrohRelayPolicyCache = CmxIrohRelayPolicyCache(), preferenceStore: CmxIrohRelayPreferenceStore = CmxIrohRelayPreferenceStore(), credentialStore: CmxIrohCustomRelayCredentialStore = CmxIrohCustomRelayCredentialStore(), - broker: (any CmxIrohRelayPolicyServing)? = nil + broker: (any CmxIrohRelayPolicyServing)? = nil, + expiredPolicyReuseGrace: TimeInterval = CmxIrohRelayPolicyService + .defaultExpiredPolicyReuseGrace ) { self.policyCache = policyCache self.preferenceStore = preferenceStore self.credentialStore = credentialStore self.broker = broker + self.expiredPolicyReuseGrace = max(0, expiredPolicyReuseGrace) } - /// Fetches and installs the broker's current relay bootstrap response. + /// Fetches and installs the broker's current signed relay policy. + /// + /// Every failure of this authenticated round (fetch or verification) + /// extends the refresh failure streak published with diagnostics; a + /// success clears it. Direct ``install(response:accountID:trustRoot:now:)`` + /// and ``restore(accountID:trustRoot:now:)`` calls leave the streak + /// untouched: restore is the failed-refresh fallback, not a refresh. @discardableResult public func refresh( - endpointID: CmxIrohPeerIdentity, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { - try await refreshWithCredential( - endpointID: endpointID, - accountID: accountID, - trustRoot: trustRoot, - now: now - ).effective - } - - /// One resolved bootstrap: the effective policy plus the broker-minted - /// relay credential from the same response, so activation can install the - /// credential without a second mint request. - public struct RefreshOutcome: Sendable { - public let effective: CmxIrohEffectiveRelayPolicy - public let relayCredential: CmxIrohRelayTokenResponse? - } - - /// Fetches and installs the broker's current relay bootstrap response, - /// returning the minted credential alongside the effective policy. - public func refreshWithCredential( - endpointID: CmxIrohPeerIdentity, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date = Date() - ) async throws -> RefreshOutcome { guard let broker else { throw CmxIrohRelayPolicyServiceError.brokerUnavailable } - let bootstrap = try await broker.issueRelayBootstrap(endpointID: endpointID) - let effective = try await install( - response: bootstrap.relayPolicy, - accountID: accountID, - trustRoot: trustRoot, - relayCredential: bootstrap.relayToken, - now: now - ) - return RefreshOutcome( - effective: effective, - // Return only the credential accepted by policy resolution. A - // rejected bootstrap must not displace a valid cached credential. - relayCredential: effective.relayBootstrap - ) + let response: CmxIrohRelayPolicyResponse + do { + response = try await broker.fetchRelayPolicy() + } catch { + recordRefreshFailure(at: now) + throw error + } + do { + let effective = try await install( + response: response, + accountID: accountID, + trustRoot: trustRoot, + now: now + ) + clearRefreshFailureStreak() + return effective + } catch { + recordRefreshFailure(at: now) + throw error + } } /// Verifies and resolves one broker response without replacing last-known-good @@ -84,7 +93,6 @@ public actor CmxIrohRelayPolicyService { response: CmxIrohRelayPolicyResponse, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse?, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -105,7 +113,6 @@ public actor CmxIrohRelayPolicyService { configuration: response.preference, revision: response.preferenceRevision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: false, @@ -143,7 +150,6 @@ public actor CmxIrohRelayPolicyService { public func restore( accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -180,7 +186,6 @@ public actor CmxIrohRelayPolicyService { configuration: persisted.requested, revision: persisted.revision, policy: policy, - relayCredential: nil, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: policy != nil, @@ -196,7 +201,15 @@ public actor CmxIrohRelayPolicyService { } do { - guard let policy = try await policyCache.load(trustRoot: trustRoot, now: now) else { + // Restore is the failed-refresh fallback path, so it alone grants + // the bounded expired-policy grace: a recently-expired last-good + // catalog must stay dialable instead of zeroing every relay route + // (cmux#10375). The graced state is visible as `.policyExpired`. + guard let policy = try await policyCache.load( + trustRoot: trustRoot, + now: now, + expiredPolicyReuseGrace: expiredPolicyReuseGrace + ) else { return publishUnavailable( configuration: persisted.requested, revision: persisted.revision, @@ -205,11 +218,12 @@ public actor CmxIrohRelayPolicyService { failure: .policyUnavailable ) } + let policyIsExpired = TimeInterval(policy.expiresAt) + <= now.timeIntervalSince1970 let resolution = await Resolver.resolve( configuration: persisted.requested, revision: persisted.revision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: true, @@ -218,7 +232,9 @@ public actor CmxIrohRelayPolicyService { return commit( Resolution( effective: resolution.effective, - failure: cleanupFailure ?? resolution.failure + failure: policyIsExpired + ? .policyExpired + : cleanupFailure ?? resolution.failure ), operation: operation ) @@ -247,7 +263,6 @@ public actor CmxIrohRelayPolicyService { _ preference: CmxIrohAccountRelayPreference, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let current: CmxIrohAccountRelayConfiguration @@ -261,7 +276,6 @@ public actor CmxIrohRelayPolicyService { current.updatingActivePreference(preference), accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now ) } @@ -274,7 +288,6 @@ public actor CmxIrohRelayPolicyService { _ configuration: CmxIrohAccountRelayConfiguration, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, now: Date = Date() ) async throws -> CmxIrohEffectiveRelayPolicy { let operation = beginOperation() @@ -299,7 +312,6 @@ public actor CmxIrohRelayPolicyService { authoritative, accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now, operation: operation ) @@ -310,7 +322,6 @@ public actor CmxIrohRelayPolicyService { response, accountID: accountID, trustRoot: trustRoot, - relayCredential: relayCredential, now: now, operation: operation ) @@ -336,7 +347,6 @@ public actor CmxIrohRelayPolicyService { _ response: CmxIrohRelayPreferenceResponse, accountID: String, trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse?, now: Date, operation: UInt64 ) async throws -> CmxIrohEffectiveRelayPolicy { @@ -352,7 +362,6 @@ public actor CmxIrohRelayPolicyService { configuration: response.preference, revision: response.revision, policy: policy, - relayCredential: relayCredential, accountID: accountID, credentialStore: credentialStore, usedCachedPolicy: policy != nil, @@ -490,15 +499,12 @@ public actor CmxIrohRelayPolicyService { failure: CmxIrohRelayPolicyFailure? ) { currentEffective = effective - currentDiagnostics = Resolver.diagnostics(for: effective, failure: failure) - for continuation in continuations.values { - continuation.yield(currentDiagnostics) - } + yieldDiagnostics(Resolver.diagnostics(for: effective, failure: failure)) } private func publishFailure(_ failure: CmxIrohRelayPolicyFailure) { guard let effective = currentEffective else { - currentDiagnostics = CmxIrohRelayDiagnosticsSnapshot( + yieldDiagnostics(CmxIrohRelayDiagnosticsSnapshot( source: .inactive, policyID: nil, policySequence: nil, @@ -509,18 +515,44 @@ public actor CmxIrohRelayPolicyService { staleRelayIDs: [], missingCredentialRelayIDs: [], failure: failure - ) - for continuation in continuations.values { - continuation.yield(currentDiagnostics) - } + )) return } - currentDiagnostics = Resolver.diagnostics(for: effective, failure: failure) + yieldDiagnostics(Resolver.diagnostics(for: effective, failure: failure)) + } + + /// The single diagnostics funnel: every published snapshot carries the + /// current refresh failure streak. + private func yieldDiagnostics(_ snapshot: CmxIrohRelayDiagnosticsSnapshot) { + currentDiagnostics = snapshot.withRefreshFailureStreak( + since: refreshFailingSince, + count: consecutiveRefreshFailures + ) for continuation in continuations.values { continuation.yield(currentDiagnostics) } } + private func recordRefreshFailure(at now: Date) { + consecutiveRefreshFailures = min(consecutiveRefreshFailures + 1, 1_000) + if refreshFailingSince == nil { + refreshFailingSince = now + } + // Re-publish so observers see the streak grow even when the failed + // round produced no new resolution (e.g. the broker fetch itself + // failed before install could publish anything). + yieldDiagnostics(currentDiagnostics) + } + + private func clearRefreshFailureStreak() { + guard refreshFailingSince != nil || consecutiveRefreshFailures > 0 else { + return + } + refreshFailingSince = nil + consecutiveRefreshFailures = 0 + yieldDiagnostics(currentDiagnostics) + } + private func removeContinuation(_ id: UUID) { continuations.removeValue(forKey: id) } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift index 0474fb51ee6b..5e3d6115fde1 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift @@ -3,9 +3,6 @@ public enum CmxIrohRelayPolicyServiceError: Error, Equatable, Sendable { /// No broker was injected for a network-backed operation. case brokerUnavailable - /// A managed bootstrap omitted its endpoint-scoped relay credential. - case managedCredentialUnavailable - /// A preference revision rolled back or equivocated. case preferenceRollback diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift index 6387636c0bd2..3616d7c087ee 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift @@ -1,11 +1,7 @@ -public import CMUXMobileCore - /// Authenticated broker operations used by the relay policy service. public protocol CmxIrohRelayPolicyServing: Sendable { - /// Issues endpoint-scoped relay bootstrap material. - func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse + /// Fetches the signed managed relay policy and account preference. + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse /// Fetches the current account relay preference. func relayPreference() async throws -> CmxIrohRelayPreferenceResponse diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift index 454057efc53a..316dc21a1ea6 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift @@ -36,7 +36,15 @@ public struct CmxIrohRelayPolicyTrustRoot: Equatable, Sendable { } else { return nil } - let keys = records.compactMap { record -> CmxIrohRelayPolicyVerificationKey? in + // An unused rotation slot: a build configuration that does not stage + // a key for a slot leaves both substitution variables undefined, and + // the Info.plist build expands them to empty strings. Only the + // exactly-empty pair is skipped; a partially filled or otherwise + // invalid record still fails the whole trust root closed below. + let activeRecords = records.filter { record in + !(record["keyID"] == "" && record["publicKeyBase64"] == "") + } + let keys = activeRecords.compactMap { record -> CmxIrohRelayPolicyVerificationKey? in guard let keyID = record["keyID"], let publicKey = record["publicKeyBase64"] else { return nil } return try? CmxIrohRelayPolicyVerificationKey( @@ -44,7 +52,7 @@ public struct CmxIrohRelayPolicyTrustRoot: Equatable, Sendable { rawPublicKeyBase64: publicKey ) } - guard keys.count == records.count else { return nil } + guard keys.count == activeRecords.count else { return nil } return try? CmxIrohRelayPolicyTrustRoot(keys: keys) } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayRefreshSchedule.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayRefreshSchedule.swift deleted file mode 100644 index c75242039988..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayRefreshSchedule.swift +++ /dev/null @@ -1,47 +0,0 @@ -import CMUXMobileCore -import Foundation - -/// Assigns endpoint-stable, non-overlapping relay credential refresh slots. -struct CmxIrohRelayRefreshSchedule: Sendable { - enum Role: Sendable { - case host - case client - - fileprivate var phaseStart: Int { - switch self { - case .host: 0 - case .client: 30 - } - } - } - - private static let phaseWidth = 15 - private static let minuteDuration: TimeInterval = 60 - private static let fnvOffsetBasis: UInt64 = 14_695_981_039_346_656_037 - private static let fnvPrime: UInt64 = 1_099_511_628_211 - - private let secondWithinMinute: Int - - init(role: Role, endpointIdentity: CmxIrohPeerIdentity) { - var hash = Self.fnvOffsetBasis - for byte in endpointIdentity.endpointID.utf8 { - hash ^= UInt64(byte) - hash &*= Self.fnvPrime - } - secondWithinMinute = role.phaseStart + Int(hash % UInt64(Self.phaseWidth)) - } - - func deadline(now: Date, refreshAfter: Date) -> Date { - let refreshEpoch = refreshAfter.timeIntervalSince1970 - let minuteStart = floor(refreshEpoch / Self.minuteDuration) - * Self.minuteDuration - var candidateEpoch = minuteStart + TimeInterval(secondWithinMinute) - if candidateEpoch > refreshEpoch { - candidateEpoch -= Self.minuteDuration - } - return min( - refreshAfter, - max(now, Date(timeIntervalSince1970: candidateEpoch)) - ) - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift deleted file mode 100644 index 2d18da34bc70..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift +++ /dev/null @@ -1,115 +0,0 @@ -public import Foundation - -/// Endpoint-scoped credentials for one exact managed relay fleet. -public struct CmxIrohRelayTokenResponse: Codable, Equatable, Sendable { - /// URL-keyed relay credentials returned by the broker. - public let credentials: [CmxIrohManagedRelayCredential] - - /// The complete ordered managed relay fleet covered by the response. - public var relayFleet: [String] { - credentials.map(\.relayURL) - } - - /// Creates a response containing independently issued relay credentials. - /// - /// - Parameter credentials: One credential for every signed managed relay. - public init(credentials: [CmxIrohManagedRelayCredential]) { - self.credentials = credentials - } - - /// Creates a legacy homogeneous-fleet response for cache and API migration. - /// - /// New broker responses should use ``init(credentials:)``. This initializer - /// remains so a single legacy token can be expanded into the URL-keyed model. - /// - /// - Parameters: - /// - token: One token accepted by every relay in `relayFleet`. - /// - expiresAt: The shared provider-enforced expiry in ISO 8601 format. - /// - refreshAfter: The shared replacement time in ISO 8601 format. - /// - relayFleet: The complete managed relay fleet covered by the token. - public init( - token: String, - expiresAt: String, - refreshAfter: String, - relayFleet: [String] - ) { - credentials = relayFleet.map { - CmxIrohManagedRelayCredential( - relayURL: $0, - token: token, - expiresAt: expiresAt, - refreshAfter: refreshAfter - ) - } - } - - /// Decodes the URL-keyed wire format or the legacy homogeneous-fleet format. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if container.contains(.credentials) { - self.init( - credentials: try container.decode( - [CmxIrohManagedRelayCredential].self, - forKey: .credentials - ) - ) - return - } - let token = try container.decode(String.self, forKey: .token) - let expiresAt = try container.decode(String.self, forKey: .expiresAt) - let refreshAfter = try container.decode(String.self, forKey: .refreshAfter) - let relayFleet = try container.decode([String].self, forKey: .relayFleet) - self.init( - token: token, - expiresAt: expiresAt, - refreshAfter: refreshAfter, - relayFleet: relayFleet - ) - } - - /// Encodes only the URL-keyed format so newly persisted state is unambiguous. - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(credentials, forKey: .credentials) - } - - /// Validates every URL-token association and creates endpoint credentials. - /// - /// - Parameter now: The validation time. - /// - Returns: One configuration for every unique relay URL. - /// - Throws: A coarse invalid-response error for malformed, stale, duplicate, - /// or over-sized credential sets. - public func relayConfigurations(now: Date) throws -> [CmxIrohRelayConfiguration] { - guard (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - credentials.count - ), - Set(credentials.map(\.relayURL)).count == credentials.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - do { - return try credentials.map { credential in - guard let expiresAt = CmxIrohISO8601Date.parse(credential.expiresAt), - let refreshAfter = CmxIrohISO8601Date.parse(credential.refreshAfter) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return try CmxIrohRelayConfiguration( - url: credential.relayURL, - token: credential.token, - expiresAt: expiresAt, - refreshAfter: refreshAfter, - now: now - ) - } - } catch { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - } - - private enum CodingKeys: String, CodingKey { - case credentials = "relay_credentials" - case token - case expiresAt = "expires_at" - case refreshAfter = "refresh_after" - case relayFleet = "relay_fleet" - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift deleted file mode 100644 index 3b7e0e366aba..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift +++ /dev/null @@ -1,12 +0,0 @@ -public import CMUXMobileCore - -/// Narrow trust-broker boundary used by relay credential rotation. -public protocol CmxIrohRelayTokenServing: Sendable { - /// Issues a fresh endpoint-bound credential for the managed relay fleet. - func issueRelayToken( - bindingID: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse -} - -extension CmxIrohTrustBrokerClient: CmxIrohRelayTokenServing {} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift deleted file mode 100644 index 772165665a8c..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -extension CmxIrohHostRuntimeConfiguration { - func resolvedEndpointRelayProfile(now: Date) throws -> CmxIrohEndpointRelayProfile { - try resolveEndpointRelayProfile( - configured: endpointRelayProfile, - managedRelayURLs: managedRelayURLs, - cachedRelayCredential: cachedRelayCredential, - now: now - ) - } -} - -extension CmxIrohClientRuntimeConfiguration { - func resolvedEndpointRelayProfile(now: Date) throws -> CmxIrohEndpointRelayProfile { - try resolveEndpointRelayProfile( - configured: endpointRelayProfile, - managedRelayURLs: managedRelayURLs, - cachedRelayCredential: cachedRelayCredential, - now: now - ) - } -} - -private func resolveEndpointRelayProfile( - configured: CmxIrohEndpointRelayProfile?, - managedRelayURLs: Set, - cachedRelayCredential: CmxIrohRelayTokenResponse?, - now: Date -) throws -> CmxIrohEndpointRelayProfile { - let base = try configured ?? CmxIrohEndpointRelayProfile( - managedRelayURLs: managedRelayURLs, - relays: [] - ) - guard base.source == .managed, - let cachedRelayCredential, - cachedRelayCredential.relayFleet.count == managedRelayURLs.count, - Set(cachedRelayCredential.relayFleet) == managedRelayURLs, - let cached = try? cachedRelayCredential.relayConfigurations(now: now) else { - return base - } - let selected = cached.filter { base.allowedRelayURLs.contains($0.url) } - guard selected.count == base.allowedRelayURLs.count else { return base } - return try base.replacingManagedRelays(selected) -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift deleted file mode 100644 index 7372cf490dd9..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation - -/// Versioned Keychain payload for one binding-scoped relay capability. -struct CmxIrohStoredRelayCredential: Codable, Equatable, Sendable { - static let currentVersion = 2 - - let version: Int - let binding: CmxIrohBrokerBindingMetadata - let response: CmxIrohRelayTokenResponse - - init( - binding: CmxIrohBrokerBindingMetadata, - response: CmxIrohRelayTokenResponse - ) { - version = Self.currentVersion - self.binding = binding - self.response = response - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let storedVersion = try container.decode(Int.self, forKey: .version) - binding = try container.decode(CmxIrohBrokerBindingMetadata.self, forKey: .binding) - switch storedVersion { - case 1: - response = CmxIrohRelayTokenResponse( - token: try container.decode(String.self, forKey: .token), - expiresAt: try container.decode(String.self, forKey: .expiresAt), - refreshAfter: try container.decode(String.self, forKey: .refreshAfter), - relayFleet: try container.decode([String].self, forKey: .relayFleet) - ) - case Self.currentVersion: - response = try container.decode( - CmxIrohRelayTokenResponse.self, - forKey: .response - ) - default: - throw DecodingError.dataCorruptedError( - forKey: .version, - in: container, - debugDescription: "Unsupported relay credential version" - ) - } - version = Self.currentVersion - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(version, forKey: .version) - try container.encode(binding, forKey: .binding) - try container.encode(response, forKey: .response) - } - - private enum CodingKeys: String, CodingKey { - case version - case binding - case response - case token - case expiresAt - case refreshAfter - case relayFleet - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift index 970206b712f5..a9999f351931 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift @@ -230,41 +230,10 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { } private struct BindingRequest: Encodable { let bindingId: String } - private struct EndpointRequest: Encodable { let endpointId: String } - private struct RelayAccessCredential: Decodable, Sendable { - let relayUrl: String - let token: String - let expiresAt: Int64 - let refreshAfter: Int64 - let ttlSeconds: Int64 - } - private struct RelayAccessResponse: Decodable, Sendable { - let token: String? - let expiresAt: Int64? - let ttlSeconds: Int64? - let relays: [String]? - let endpointId: String? - let relayCredentials: [RelayAccessCredential]? - let policy: String? - let preference: CmxIrohAccountRelayConfiguration? - let preferenceRevision: Int64? - } - private struct RelayTokenHeader: Decodable { - let alg: String - let typ: String - } - private struct RelayTokenClaims: Decodable { - let issuer: String - let audience: String - let expiresAt: Int64 - let endpointID: String - - private enum CodingKeys: String, CodingKey { - case issuer = "iss" - case audience = "aud" - case expiresAt = "exp" - case endpointID = "endpoint_id" - } + private struct RelayPolicyBootstrapResponse: Decodable, Sendable { + let policy: String + let preference: CmxIrohAccountRelayConfiguration + let preferenceRevision: Int64 } private struct PairGrantRequest: Encodable { let initiatorBindingId: String @@ -467,54 +436,22 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { ) } - public func issueRelayToken( - bindingID _: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - let response: RelayAccessResponse = try await send( - path: "api/relay/token", - method: "POST", - body: EndpointRequest(endpointId: endpointID.endpointID), - operation: .relayCredential - ) - return try Self.relayTokenResponse(response, endpointID: endpointID) - } - - /// Issues a managed credential together with signed, server-driven relay policy. - public func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { - let response: RelayAccessResponse = try await send( - path: "api/relay/token", - method: "POST", - body: EndpointRequest(endpointId: endpointID.endpointID), + /// Fetches the signed, server-driven relay policy for the account. + public func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { + let response: RelayPolicyBootstrapResponse = try await sendWithoutBody( + path: "api/relay/policy", + method: "GET", operation: .relayCredential ) - guard let policy = response.policy, - let preference = response.preference, - let preferenceRevision = response.preferenceRevision else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let policyResponse: CmxIrohRelayPolicyResponse do { - policyResponse = try CmxIrohRelayPolicyResponse( - policy: policy, - preference: preference, - preferenceRevision: preferenceRevision + return try CmxIrohRelayPolicyResponse( + policy: response.policy, + preference: response.preference, + preferenceRevision: response.preferenceRevision ) } catch { throw CmxIrohTrustBrokerClientError.invalidResponse } - let relayToken: CmxIrohRelayTokenResponse? - if response.relayCredentials == nil, response.token == nil { - relayToken = nil - } else { - relayToken = try Self.relayTokenResponse(response, endpointID: endpointID) - } - return CmxIrohRelayBootstrapResponse( - relayToken: relayToken, - relayPolicy: policyResponse - ) } /// Fetches the current account relay preference. @@ -887,6 +824,14 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = requestTimeout + // A debug-only deployment-protection bypass lets a tagged test build + // reach a protected broker preview; nil in release builds. + if let bypass = CmxIrohDebugBrokerBypassHeader.activeValue() { + request.setValue( + bypass, + forHTTPHeaderField: CmxIrohDebugBrokerBypassHeader.headerField + ) + } request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token") request.setValue(clientNamespace, forHTTPHeaderField: "X-Cmux-App-Namespace") @@ -984,130 +929,6 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { return seconds } - private static func relayTokenResponse( - _ response: RelayAccessResponse, - endpointID: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - if let credentials = response.relayCredentials { - guard response.endpointId == endpointID.endpointID, - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - credentials.count - ) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let relayCredentials = try credentials.map { credential in - guard (30 ... 24 * 60 * 60).contains(credential.ttlSeconds), - credential.expiresAt > credential.refreshAfter, - credential.refreshAfter - >= credential.expiresAt - credential.ttlSeconds, - (1 ... 8 * 1_024).contains(credential.token.utf8.count) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohManagedRelayCredential( - relayURL: try canonicalRelayOrigin(credential.relayUrl), - token: credential.token, - expiresAt: iso8601(epochSeconds: credential.expiresAt), - refreshAfter: iso8601(epochSeconds: credential.refreshAfter) - ) - } - guard Set(relayCredentials.map(\.relayURL)).count - == relayCredentials.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohRelayTokenResponse(credentials: relayCredentials) - } - - guard let token = response.token, - let expiresAtSeconds = response.expiresAt, - let ttlSeconds = response.ttlSeconds, - let relays = response.relays, - ttlSeconds == 300, - expiresAtSeconds > ttlSeconds, - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - relays.count - ), - validRelayToken( - token, - expiresAt: expiresAtSeconds, - endpointID: endpointID - ) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let relayFleet = try relays.map(canonicalRelayOrigin) - guard Set(relayFleet).count == relayFleet.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let refreshLead = min(60, ttlSeconds / 2) - return CmxIrohRelayTokenResponse( - token: token, - expiresAt: iso8601(epochSeconds: expiresAtSeconds), - refreshAfter: iso8601(epochSeconds: expiresAtSeconds - refreshLead), - relayFleet: relayFleet - ) - } - - private static func iso8601(epochSeconds: Int64) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string( - from: Date(timeIntervalSince1970: TimeInterval(epochSeconds)) - ) - } - - private static func validRelayToken( - _ token: String, - expiresAt: Int64, - endpointID: CmxIrohPeerIdentity - ) -> Bool { - guard (1 ... 8 * 1_024).contains(token.utf8.count) else { return false } - let segments = token.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count == 3, - let headerData = base64URLData(segments[0]), - let claimsData = base64URLData(segments[1]), - let header = try? JSONDecoder().decode(RelayTokenHeader.self, from: headerData), - let claims = try? JSONDecoder().decode(RelayTokenClaims.self, from: claimsData) else { - return false - } - return header.alg == "EdDSA" - && header.typ == "JWT" - && claims.issuer == "cmux" - && claims.audience == "cmux-relay" - && claims.expiresAt == expiresAt - && claims.endpointID == endpointID.endpointID - } - - private static func base64URLData(_ value: Substring) -> Data? { - var encoded = String(value) - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let remainder = encoded.utf8.count % 4 - if remainder != 0 { - encoded.append(String(repeating: "=", count: 4 - remainder)) - } - return Data(base64Encoded: encoded) - } - - private static func canonicalRelayOrigin(_ value: String) throws -> String { - guard var components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.port == nil, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path.isEmpty || components.path == "/" else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - components.path = "/" - guard let canonical = components.string else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return canonical - } - private static func isConnectivityFailure(_ code: URLError.Code) -> Bool { switch code { case .timedOut, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift index e0a1ae918e16..866a27be01b2 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift @@ -45,14 +45,6 @@ struct ClientRuntimeTestFixture { ) } - func relayResponse() -> CmxIrohRelayTokenResponse { - CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-07-10T12:00:00.000Z", - refreshAfter: "2027-07-10T11:00:00.000Z", - relayFleet: Self.relayURLs - ) - } func pendingRevocations() -> CmxIrohPendingRevocationOutbox { CmxIrohPendingRevocationOutbox( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBackpressuredHostBrokerTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBackpressuredHostBrokerTests.swift index 416483e0084a..c736ca5f0613 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBackpressuredHostBrokerTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBackpressuredHostBrokerTests.swift @@ -6,7 +6,7 @@ import Testing @Suite struct CmxIrohBackpressuredHostBrokerTests { @Test - func endpointAttestationFloorIsIsolatedAndRelayCredentialIsShared() async throws { + func endpointAttestationFloorIsIsolatedFromRelayPolicyFloor() async throws { let now = Date(timeIntervalSince1970: 1_782_000_000) let accountID = "account-a" let gate = CmxIrohBrokerBackpressureGate(now: { now }) @@ -21,9 +21,6 @@ struct CmxIrohBackpressuredHostBrokerTests { gate: gate, accountID: accountID ) - let endpointID = try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ) let attestationLimit = CmxIrohTrustBrokerClientError.rateLimited( code: "attestation_rate_limited", retryAfterSeconds: 600 @@ -38,8 +35,7 @@ struct CmxIrohBackpressuredHostBrokerTests { } #expect(await probe.calls() == BackpressuredHostBrokerProbeCalls( endpointAttestation: 1, - relayToken: 0, - relayBootstrap: 0 + relayPolicy: 0 )) #expect(await gate.remainingSeconds( accountID: accountID, @@ -51,40 +47,31 @@ struct CmxIrohBackpressuredHostBrokerTests { ) == nil) await #expect(throws: relayLimit) { - _ = try await host.issueRelayToken( - bindingID: "binding-a", - endpointID: endpointID - ) + _ = try await relayPolicy.fetchRelayPolicy() } #expect(await probe.calls() == BackpressuredHostBrokerProbeCalls( endpointAttestation: 1, - relayToken: 1, - relayBootstrap: 0 + relayPolicy: 1 )) - #expect(await gate.remainingSeconds( - accountID: accountID, - operation: .endpointAttestation - ) == 600) #expect(await gate.remainingSeconds( accountID: accountID, operation: .relayCredential ) == 600) + // The armed floor gates the next policy fetch without a broker call. await #expect(throws: relayLimit) { - _ = try await relayPolicy.issueRelayBootstrap(endpointID: endpointID) + _ = try await relayPolicy.fetchRelayPolicy() } #expect(await probe.calls() == BackpressuredHostBrokerProbeCalls( endpointAttestation: 1, - relayToken: 1, - relayBootstrap: 0 + relayPolicy: 1 )) } } private struct BackpressuredHostBrokerProbeCalls: Equatable, Sendable { let endpointAttestation: Int - let relayToken: Int - let relayBootstrap: Int + let relayPolicy: Int } private enum BackpressuredHostBrokerProbeError: Error, Sendable { @@ -96,8 +83,7 @@ private actor BackpressuredHostBrokerProbe: CmxIrohRelayPolicyServing { private var endpointAttestationCalls = 0 - private var relayTokenCalls = 0 - private var relayBootstrapCalls = 0 + private var relayPolicyCalls = 0 func register( prepared _: CmxIrohPreparedRegistration, @@ -120,17 +106,6 @@ private actor BackpressuredHostBrokerProbe: ) } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - relayTokenCalls += 1 - throw CmxIrohTrustBrokerClientError.rateLimited( - code: "relay_rate_limited", - retryAfterSeconds: 600 - ) - } - func revoke(bindingID _: String) async throws { throw BackpressuredHostBrokerProbeError.unexpectedCall } @@ -139,11 +114,12 @@ private actor BackpressuredHostBrokerProbe: throw BackpressuredHostBrokerProbeError.unexpectedCall } - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { - relayBootstrapCalls += 1 - throw BackpressuredHostBrokerProbeError.unexpectedCall + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { + relayPolicyCalls += 1 + throw CmxIrohTrustBrokerClientError.rateLimited( + code: "relay_rate_limited", + retryAfterSeconds: 600 + ) } func relayPreference() async throws -> CmxIrohRelayPreferenceResponse { @@ -159,8 +135,7 @@ private actor BackpressuredHostBrokerProbe: func calls() -> BackpressuredHostBrokerProbeCalls { BackpressuredHostBrokerProbeCalls( endpointAttestation: endpointAttestationCalls, - relayToken: relayTokenCalls, - relayBootstrap: relayBootstrapCalls + relayPolicy: relayPolicyCalls ) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift index d5027cfdd40c..88091e24974f 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift @@ -1,26 +1,17 @@ import CMUXMobileCore +import CryptoKit import Foundation import Testing @testable import CmuxIrohTransport @Suite("Iroh broker credential repository") struct CmxIrohBrokerCredentialRepositoryTests { - private let now = Date(timeIntervalSince1970: 1_800_000_000) private let relayFleet = [ "https://use1-1.relay.lawrence.cmux.iroh.link/", "https://usw1-1.relay.lawrence.cmux.iroh.link/", ] - @Test("credential descriptions redact opaque tokens") - func credentialDescriptionsRedactTokens() { - let credential = relayResponse().credentials[0] - - #expect(!String(describing: credential).contains(credential.token)) - #expect(!String(reflecting: credential).contains(credential.token)) - #expect(String(describing: credential).contains("")) - } - - @Test("binding metadata and relay credentials survive repository recreation") + @Test("binding metadata survives repository recreation") func roundTripsDurableState() async throws { let (defaults, suiteName) = try isolatedDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } @@ -32,17 +23,9 @@ struct CmxIrohBrokerCredentialRepositoryTests { privacyScope: .publicInternet ) let binding = try metadata(pathHints: [pathHint]) - let response = relayResponse() let repository = makeRepository(defaults: defaults, secureStore: secureStore) try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) let recreated = makeRepository(defaults: defaults, secureStore: secureStore) #expect( @@ -51,343 +34,72 @@ struct CmxIrohBrokerCredentialRepositoryTests { appInstanceID: binding.appInstanceID ) == binding ) - #expect( - try await recreated.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == response - ) - #expect( - await secureStore.observedAccessibilities() - == [.afterFirstUnlockThisDeviceOnly] - ) - #expect( - !defaults.dictionaryRepresentation().values.contains(where: { value in - response.credentials.contains { credential in - String(describing: value).contains(credential.token) - } - }) - ) - } - - @Test("distinct per-relay credentials survive device-only persistence") - func roundTripsDistinctPerRelayCredentials() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let binding = try metadata() - let response = CmxIrohRelayTokenResponse(credentials: [ - CmxIrohManagedRelayCredential( - relayURL: relayFleet[0], - token: "abc234", - expiresAt: iso8601(now.addingTimeInterval(2 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(60 * 60)) - ), - CmxIrohManagedRelayCredential( - relayURL: relayFleet[1], - token: "def567", - expiresAt: iso8601(now.addingTimeInterval(3 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(90 * 60)) - ), - ]) - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == response - ) - let stored = try #require(await secureStore.onlyStoredData()) - let object = try #require( - JSONSerialization.jsonObject(with: stored) as? [String: Any] - ) - #expect(object["version"] as? Int == 2) - #expect(object["token"] == nil) - #expect(object["response"] != nil) + // Tokenless transport: nothing is ever written to secure storage. + #expect(await secureStore.recordCount() == 0) } - @Test("version-one homogeneous credentials migrate without a new network mint") - func loadsVersionOneCredentialRecord() async throws { + @Test("scope rotation deletes prior state and legacy secure records") + func scopeRotationDeletesPriorState() async throws { let (defaults, suiteName) = try isolatedDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } let secureStore = TestSecureCredentialStore() let binding = try metadata() let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let legacyResponse = relayResponse() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - legacyResponse, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - let account = try #require(await secureStore.lastDeletedOrWrittenAccount()) - let bindingObject = try JSONSerialization.jsonObject( - with: JSONEncoder().encode(binding) - ) - let legacyRecord: [String: Any] = [ - "version": 1, - "binding": bindingObject, - "token": "abc234", - "expiresAt": iso8601(now.addingTimeInterval(2 * 60 * 60)), - "refreshAfter": iso8601(now.addingTimeInterval(60 * 60)), - "relayFleet": relayFleet, - ] - await secureStore.seed( - try JSONSerialization.data(withJSONObject: legacyRecord), - account: account - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == legacyResponse - ) - } - - @Test("a different account or app instance cannot resurrect prior state") - func scopeRotationDeletesPriorState() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let original = try metadata() - - try await repository.saveBinding(original, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now + // A legacy relay-credential record left behind by a token-era build. + try await secureStore.write( + Data("legacy-token".utf8), + account: "legacy-scope", + accessibility: .afterFirstUnlockThisDeviceOnly ) #expect( try await repository.loadBinding( accountID: "account-b", - appInstanceID: original.appInstanceID + appInstanceID: binding.appInstanceID ) == nil ) #expect(await secureStore.recordCount() == 0) - - let replacementAppInstanceID = "123e4567-e89b-42d3-a456-426614174099" - #expect( - try await repository.loadBinding( - accountID: "account-b", - appInstanceID: replacementAppInstanceID - ) == nil - ) - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: original.appInstanceID - ) == nil - ) - #expect(await secureStore.deleteAllCount() == 4) - } - - @Test("replacing the exact broker binding invalidates its relay capability") - func bindingRotationDeletesRelayCredential() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let original = try metadata() - let rotated = try metadata( - bindingID: "123e4567-e89b-42d3-a456-426614174020", - endpointByte: "cd", - generation: 2 - ) - - try await repository.saveBinding(original, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now - ) - try await repository.saveBinding(rotated, accountID: "account-a") - #expect( try await repository.loadBinding( accountID: "account-a", - appInstanceID: original.appInstanceID - ) == rotated - ) - #expect(await secureStore.recordCount() == 0) - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now - ) == nil - ) - } - - @Test("saving an incomplete relay fleet fails without persisting the token") - func saveRejectsFleetMismatch() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - - await #expect( - throws: CmxIrohBrokerCredentialRepositoryError.relayFleetMismatch - ) { - try await repository.saveRelayCredential( - relayResponse(relayFleet: [relayFleet[0]]), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - } - #expect(await secureStore.recordCount() == 0) - } - - @Test("loading with a changed managed fleet deletes the stale capability") - func loadRejectsFleetMismatch() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set([relayFleet[0]]), - now: now + appInstanceID: binding.appInstanceID ) == nil ) - #expect(await secureStore.recordCount() == 0) } - @Test("expired or refresh-stale relay capabilities are deleted") - func loadRejectsStaleCredential() async throws { + @Test("binding replacement deletes any legacy token-era secure record") + func bindingRotationDeletesLegacySecureRecord() async throws { let (defaults, suiteName) = try isolatedDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } let secureStore = TestSecureCredentialStore() let repository = makeRepository(defaults: defaults, secureStore: secureStore) let binding = try metadata() - let response = relayResponse() try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now + // A token-era build stored the relay credential under the scope key. + try await secureStore.write( + Data("legacy-token".utf8), + account: scope(accountID: "account-a", appInstanceID: binding.appInstanceID), + accessibility: .afterFirstUnlockThisDeviceOnly ) - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now.addingTimeInterval(60 * 60) - ) == nil + let replacement = try metadata( + bindingID: "123e4567-e89b-42d3-a456-426614174020", + endpointByte: "cd", + generation: 2 ) - #expect(await secureStore.recordCount() == 0) + try await repository.saveBinding(replacement, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now.addingTimeInterval(2 * 60 * 60) - ) == nil - ) #expect(await secureStore.recordCount() == 0) - } - - @Test("corrupt secure records fail closed and are removed") - func corruptCredentialIsDeleted() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - let account = try #require(await secureStore.lastDeletedOrWrittenAccount()) - await secureStore.seed(Data("not-json".utf8), account: account) - #expect( - try await repository.loadRelayCredential( + try await repository.loadBinding( accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - } - - @Test("persisted binding metadata is revalidated during decoding") - func corruptBindingMetadataIsRejected() throws { - let binding = try metadata() - let encoded = try JSONEncoder().encode(binding) - var object = try #require( - JSONSerialization.jsonObject(with: encoded) as? [String: Any] + appInstanceID: replacement.appInstanceID + ) == replacement ) - object["bindingID"] = "not-a-uuid" - let corrupted = try JSONSerialization.data(withJSONObject: object) - - #expect(throws: CmxIrohBrokerCredentialRepositoryError.invalidBinding) { - try JSONDecoder().decode( - CmxIrohBrokerBindingMetadata.self, - from: corrupted - ) - } } - @Test("explicit deletion preserves or clears binding metadata as requested") + @Test("explicit deletion and deactivation clear binding metadata") func explicitDeletion() async throws { let (defaults, suiteName) = try isolatedDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } @@ -395,25 +107,6 @@ struct CmxIrohBrokerCredentialRepositoryTests { let repository = makeRepository(defaults: defaults, secureStore: secureStore) let binding = try metadata() try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - try await repository.deleteRelayCredential( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) - #expect(await secureStore.recordCount() == 0) - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) == binding - ) try await repository.deleteBinding( accountID: "account-a", @@ -446,6 +139,15 @@ struct CmxIrohBrokerCredentialRepositoryTests { ) } + /// Mirrors the repository's deterministic scope derivation so a test can + /// seed a record where a token-era build would have stored it. + private func scope(accountID: String, appInstanceID: String) -> String { + let transcript = Data( + "cmux/iroh/broker-credential-scope/v1\0\(accountID)\0\(appInstanceID)".utf8 + ) + return SHA256.hash(data: transcript).map { String(format: "%02x", $0) }.joined() + } + private func isolatedDefaults() throws -> (UserDefaults, String) { let suiteName = "CmxIrohBrokerCredentialRepositoryTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) @@ -472,21 +174,4 @@ struct CmxIrohBrokerCredentialRepositoryTests { pathHints: pathHints ) } - - private func relayResponse( - relayFleet: [String]? = nil - ) -> CmxIrohRelayTokenResponse { - CmxIrohRelayTokenResponse( - token: "abc234", - expiresAt: iso8601(now.addingTimeInterval(2 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(60 * 60)), - relayFleet: relayFleet ?? self.relayFleet - ) - } - - private func iso8601(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: date) - } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift index fad3d4506842..a476e726c017 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift @@ -28,16 +28,9 @@ extension CmxIrohClientRuntimeTests { capabilities: discovery.bindings[0].capabilities, managedRelayURLs: [fixture.relayURL] ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) let broker = TestIrohClientBroker( binding: discovery.bindings[0], discovery: discovery, - relay: relay, pairGrant: try fixture.pairGrantResponse( issuedAt: fixture.nowSeconds, expiresAt: fixture.nowSeconds + 3_600 @@ -100,16 +93,9 @@ extension CmxIrohClientRuntimeTests { capabilities: discovery.bindings[0].capabilities, managedRelayURLs: [fixture.relayURL] ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) let broker = TestIrohClientBroker( binding: discovery.bindings[0], discovery: discovery, - relay: relay, pairGrant: try fixture.pairGrantResponse( issuedAt: fixture.nowSeconds, expiresAt: fixture.nowSeconds + 3_600 @@ -173,16 +159,9 @@ extension CmxIrohClientRuntimeTests { capabilities: discovery.bindings[0].capabilities, managedRelayURLs: [fixture.relayURL] ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) let broker = TestIrohClientBroker( binding: discovery.bindings[0], discovery: discovery, - relay: relay, registrationError: CmxIrohTrustBrokerClientError.connectivity ) let recorder = ClientRuntimeTestRecorder() @@ -218,7 +197,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rejected( statusCode: 401, code: "unauthorized" diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeEmptyFleetTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeEmptyFleetTests.swift index f066ecde6869..9b5a3cf43d81 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeEmptyFleetTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeEmptyFleetTests.swift @@ -16,8 +16,7 @@ struct CmxIrohClientRuntimeEmptyFleetTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let recorder = ClientRuntimeTestRecorder() let configuration = CmxIrohClientRuntimeConfiguration( @@ -43,8 +42,7 @@ struct CmxIrohClientRuntimeEmptyFleetTests { handleBinding: { _, _ in await recorder.recordBinding() return true - }, - handleRelayCredential: { _, _ in await recorder.recordRelay() } + } ) try await runtime.start() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift index a31f898a126e..5f144dacdf9b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift @@ -19,8 +19,7 @@ extension CmxIrohClientRuntimeTests { ) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let configuration = CmxIrohClientRuntimeConfiguration( accountID: fixture.configuration.accountID, @@ -77,8 +76,7 @@ extension CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), @@ -113,8 +111,7 @@ extension CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), @@ -146,7 +143,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [ 2: CmxIrohTrustBrokerClientError.connectivity, ], @@ -188,7 +184,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationHook: { count in if count == 1 { await endpoint.emit(.networkChanged) } } @@ -215,8 +210,7 @@ extension CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -250,8 +244,7 @@ extension CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -277,7 +270,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryHook: { count in if count == 2 { await gate.waitOnce() } } @@ -311,7 +303,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryHook: { count in if count == 2 { await gate.waitOnce() } } @@ -343,7 +334,6 @@ extension CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryHook: { count in if count == 2 { await gate.waitOnce() } } @@ -374,8 +364,7 @@ extension CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -461,9 +450,7 @@ private actor ClientRuntimeBlockingCloseEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { nil } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } func healthEvents() -> AsyncStream { healthStream } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift index 47d58effaf44..2d4017d8fd8d 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift @@ -42,8 +42,7 @@ struct CmxIrohClientRuntimeTests { ]), broker: TestRevisionedClientBroker( binding: fixture.binding, - discoveries: [discovery], - relay: fixture.relayResponse() + discoveries: [discovery] ), configuration: configuration, pendingRevocations: fixture.pendingRevocations(), @@ -69,7 +68,6 @@ struct CmxIrohClientRuntimeTests { broker: TestRevisionedClientBroker( binding: fixture.binding, discoveries: [discovery], - relay: fixture.relayResponse(), registrationRevision: 2 ), configuration: fixture.configuration, @@ -96,7 +94,6 @@ struct CmxIrohClientRuntimeTests { broker: TestRevisionedClientBroker( binding: fixture.binding, discoveries: [discovery], - relay: fixture.relayResponse(), embedInitialDiscovery: true, registrationRevision: 1 ), @@ -112,6 +109,106 @@ struct CmxIrohClientRuntimeTests { await runtime.stop() } + /// A fresh endpoint (no cached binding) must not dial a managed, + /// admission-gated relay before its broker registration is acknowledged: + /// the relay's allow hook denies an unregistered endpoint and negatively + /// caches the deny, costing the whole first activation. The endpoint + /// binds relay-less and the managed relays are installed only after + /// registration returns. + @Test + func freshEndpointWithholdsManagedRelaysUntilRegistrationIsAcknowledged() async throws { + let fixture = try ClientRuntimeTestFixture() + let discovery = try ClientRuntimeTestFixture.discovery( + binding: fixture.binding, + revision: 2 + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestRevisionedClientBroker( + binding: fixture.binding, + discoveries: [discovery], + blockedRegistrationCount: 1, + embedInitialDiscovery: true, + registrationRevision: 1 + ) + let runtime = try CmxIrohClientRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { fixture.now } + ) + + let start = Task { try await runtime.start() } + await broker.waitUntilRegistrationCount(1) + // The endpoint is bound and its registration is held in flight: no + // managed relay may be installed yet. + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect(boundConfigurations.first?.relayProfile.activeRelays.isEmpty == true) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + + await broker.releaseBlockedRegistration() + try await start.value + + let updates = await endpoint.observedRelayProfileUpdates() + #expect(updates.count == 1) + #expect( + updates.first?.allowedRelayURLs + == Set(ClientRuntimeTestFixture.relayURLs) + ) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A cached binding proves the broker already acknowledged this endpoint, + /// so the managed relays stay installed at bind and no post-registration + /// relay swap happens. + @Test + func cachedBindingKeepsManagedRelaysInstalledAtBind() async throws { + let fixture = try ClientRuntimeTestFixture() + let discovery = try ClientRuntimeTestFixture.discovery( + binding: fixture.binding, + revision: 1 + ) + let configuration = CmxIrohClientRuntimeConfiguration( + accountID: fixture.configuration.accountID, + deviceID: fixture.configuration.deviceID, + appInstanceID: fixture.configuration.appInstanceID, + clientNamespace: fixture.configuration.clientNamespace, + tag: fixture.configuration.tag, + displayName: fixture.configuration.displayName, + identity: fixture.configuration.identity, + capabilities: fixture.configuration.capabilities, + managedRelayURLs: fixture.configuration.managedRelayURLs, + cachedBinding: CmxIrohBrokerBindingMetadata(binding: fixture.binding) + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let runtime = try CmxIrohClientRuntime( + factory: factory, + broker: TestRevisionedClientBroker( + binding: fixture.binding, + discoveries: [discovery] + ), + configuration: configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { fixture.now } + ) + + try await runtime.start() + + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect( + boundConfigurations.first?.relayProfile.allowedRelayURLs + == Set(ClientRuntimeTestFixture.relayURLs) + ) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + @Test func authoritativeRejectionCannotFallBackToStaleOfflineAuthority() async throws { let fixture = try RegistryFixture() @@ -164,12 +261,6 @@ struct CmxIrohClientRuntimeTests { managedRelayURLs: [fixture.relayURL], cachedBinding: CmxIrohBrokerBindingMetadata(binding: localBinding) ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [ TestIrohEndpoint(identity: fixture.initiator.endpointID), @@ -177,7 +268,6 @@ struct CmxIrohClientRuntimeTests { broker: TestRevisionedClientBroker( binding: localBinding, discoveries: [rejectedRevision], - relay: relay, registrationError: .connectivity ), configuration: configuration, @@ -203,7 +293,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [discovery], - relay: fixture.relayResponse(), embedInitialDiscovery: true ) let runtime = try CmxIrohClientRuntime( @@ -245,7 +334,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [authoritativeDiscovery], - relay: fixture.relayResponse(), embeddedRegistrationDiscovery: staleDiscovery, embeddedRegistrationDiscoveryIsComplete: true, registrationRevision: 1 @@ -282,7 +370,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [truncatedRegistrationDiscovery, completeDiscovery], - relay: fixture.relayResponse(), embeddedRegistrationDiscovery: truncatedRegistrationDiscovery, connectivitySnapshotsProvenComplete: nil ) @@ -335,7 +422,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: [revisionOne, revisionTwo], - relay: fixture.relayResponse(), blockedRegistrationCount: 1 ) let runtime = try CmxIrohClientRuntime( @@ -384,8 +470,7 @@ struct CmxIrohClientRuntimeTests { ) let broker = TestRevisionedClientBroker( binding: fixture.binding, - discoveries: [revisionOne, revisionTwo], - relay: fixture.relayResponse() + discoveries: [revisionOne, revisionTwo] ) let recorder = ClientRuntimeTestRecorder() let runtime = try CmxIrohClientRuntime( @@ -435,7 +520,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestRevisionedClientBroker( binding: fixture.binding, discoveries: discoveries, - relay: fixture.relayResponse(), blockedSyncCount: 2 ) let runtime = try CmxIrohClientRuntime( @@ -474,8 +558,7 @@ struct CmxIrohClientRuntimeTests { let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let recorder = ClientRuntimeTestRecorder() let runtime = try CmxIrohClientRuntime( @@ -487,8 +570,7 @@ struct CmxIrohClientRuntimeTests { handleBinding: { _, _ in await recorder.recordBinding() return true - }, - handleRelayCredential: { _, _ in await recorder.recordRelay() } + } ) try await runtime.start() @@ -503,10 +585,17 @@ struct CmxIrohClientRuntimeTests { #expect(prepared.challengeRequest.tag == fixture.binding.tag) #expect(prepared.challengeRequest.endpointId == fixture.endpointID.endpointID) #expect(prepared.challengeRequest.identityGeneration == fixture.identity.generation) - #expect(await endpoint.observedRelayUpdates().last?.count == 4) + // Tokenless transport: a fresh endpoint binds relay-less, start() + // installs the managed relays exactly once after registration is + // acknowledged, and the connect path installs no credential and + // mutates no relay further. + let relayUpdates = await endpoint.observedRelayProfileUpdates() + #expect(relayUpdates.count == 1) + #expect( + relayUpdates.first?.activeRelays + .allSatisfy { $0.authenticationToken == nil } == true + ) #expect(await recorder.observedBindingCount() == 1) - await recorder.waitForRelayCount(1) - #expect(await recorder.observedRelayCount() == 1) #expect(runtime.transportFactory.supportedKinds == [.iroh]) await runtime.stop() } @@ -516,8 +605,7 @@ struct CmxIrohClientRuntimeTests { let fixture = try ClientRuntimeTestFixture() let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let recorder = ClientRuntimeTestRecorder() let runtime = try CmxIrohClientRuntime( @@ -551,7 +639,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [ 2: CmxIrohTrustBrokerClientError.connectivity, ] @@ -591,7 +678,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: rateLimit] ) let runtime = try CmxIrohClientRuntime( @@ -620,7 +706,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -659,7 +744,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -701,7 +785,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), bindingAuthorizationAvailable: false, registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", @@ -743,7 +826,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -794,7 +876,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -839,7 +920,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), registrationError: CmxIrohTrustBrokerClientError.rateLimited( code: "device_registration_hour_quota", retryAfterSeconds: 600 @@ -873,8 +953,7 @@ struct CmxIrohClientRuntimeTests { ]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), @@ -899,8 +978,7 @@ struct CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: []), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), @@ -923,8 +1001,7 @@ struct CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: substitutedDiscovery, - relay: fixture.relayResponse() + discovery: substitutedDiscovery ) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -950,7 +1027,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [ 2: CmxIrohTrustBrokerClientError.connectivity, ] @@ -988,8 +1064,7 @@ struct CmxIrohClientRuntimeTests { ) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: factory, @@ -1024,7 +1099,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: terminal] ) let offlineStore = TestSecureCredentialStore() @@ -1059,8 +1133,7 @@ struct CmxIrohClientRuntimeTests { let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let offlineStore = TestSecureCredentialStore() let recorder = ClientRuntimeTestRecorder() @@ -1108,7 +1181,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), discoveryErrorsByCount: [2: failure] ) let offlineStore = TestSecureCredentialStore() @@ -1144,7 +1216,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), revokeError: TestIrohTransportError.unsupported ) let recorder = ClientRuntimeTestRecorder() @@ -1221,8 +1292,7 @@ struct CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: []), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: configuration, pendingRevocations: fixture.pendingRevocations(), @@ -1245,8 +1315,7 @@ struct CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: pendingRevocations, @@ -1284,8 +1353,7 @@ struct CmxIrohClientRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: pendingRevocations, @@ -1336,7 +1404,6 @@ struct CmxIrohClientRuntimeTests { let broker = TestIrohClientBroker( binding: fixture.binding, discovery: fixture.discovery, - relay: fixture.relayResponse(), revokeError: CmxIrohTrustBrokerClientError.connectivity ) let runtime = try CmxIrohClientRuntime( @@ -1374,7 +1441,6 @@ private actor TestRevisionedClientBroker: { private let binding: CmxIrohBrokerBinding private var discoveries: [CmxIrohDiscoveryResponse] - private let relay: CmxIrohRelayTokenResponse private let blockedSyncCount: Int? private let blockedRegistrationCount: Int? private let embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? @@ -1391,7 +1457,6 @@ private actor TestRevisionedClientBroker: init( binding: CmxIrohBrokerBinding, discoveries: [CmxIrohDiscoveryResponse], - relay: CmxIrohRelayTokenResponse, blockedSyncCount: Int? = nil, blockedRegistrationCount: Int? = nil, embedInitialDiscovery: Bool = false, @@ -1403,7 +1468,6 @@ private actor TestRevisionedClientBroker: ) { self.binding = binding self.discoveries = discoveries - self.relay = relay self.blockedSyncCount = blockedSyncCount self.blockedRegistrationCount = blockedRegistrationCount self.embeddedRegistrationDiscovery = embeddedRegistrationDiscovery @@ -1431,7 +1495,7 @@ private actor TestRevisionedClientBroker: ?? embeddedRegistrationDiscovery?.revision ?? discoveries.first?.revision, binding: binding, - relay: .issued(relay), + relay: .unavailable, discovery: embeddedRegistrationDiscovery, discoveryComplete: embeddedRegistrationDiscoveryIsComplete ) @@ -1472,13 +1536,6 @@ private actor TestRevisionedClientBroker: throw TestIrohTransportError.unsupported } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) -> CmxIrohRelayTokenResponse { - relay - } - func revoke(bindingID _: String) {} func revokeStale(bindingID _: String) {} @@ -1534,9 +1591,7 @@ private actor TestSubstitutedAddressEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { nil } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } func replaceRelayProfile(_: CmxIrohEndpointRelayProfile) {} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift new file mode 100644 index 000000000000..6e0f6a51d145 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionDialBoundTests.swift @@ -0,0 +1,251 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Bounded-dial behavior (cmux#9724, cmux#8531): a dial attempt whose +/// admission barrier never answers must fail at the configured dial bound and +/// leave the session redialable, instead of holding the reconnect owner for +/// an unbounded time. A half-ready Mac accepts the QUIC connection but never +/// serves the admission frames; that exact shape produced the unbounded +/// 16.2-second dial in the cmux#9724 trace. +@Suite +struct CmxIrohClientSessionDialBoundTests { + let localIdentity: CmxIrohPeerIdentity + let remoteIdentity: CmxIrohPeerIdentity + let credential: CmxIrohAdmissionCredential + + init() throws { + localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "ab", count: 32) + ) + remoteIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "cd", count: 32) + ) + credential = try .pairGrant("e30.e30.AA") + } + + @Test("an admission barrier that never answers fails at the dial bound") + func admissionBarrierThatNeverAnswersFailsAtTheDialBound() async throws { + let control = CmxIrohBidirectionalStream( + receiveStream: TestHangingIrohReceiveStream(), + sendStream: TestIrohSendStream() + ) + let connection = TestIrohConnection( + remoteIdentity: remoteIdentity, + bidirectionalStreams: [control] + ) + let endpoint = TestDialingIrohEndpoint( + localIdentity: localIdentity, + dialResults: [.connection(connection)] + ) + let session = try CmxIrohClientSession( + endpoint: endpoint, + targetIdentity: remoteIdentity, + dialPlan: try testIrohDialPlan(publicPaths: [try publicRelayHint()]), + credential: credential, + dialPhaseTimeout: .milliseconds(40) + ) + + let failure = await boundedConnectFailure(session, within: .seconds(2)) + #expect(failure as? CmxIrohClientSessionError == .dialTimedOut) + #expect(await connection.observedCloseCallCount() >= 1) + await session.close() + } + + @Test("a timed-out admission is superseded by the next connect attempt") + func timedOutAdmissionIsSupersededByTheNextConnectAttempt() async throws { + let hangingControl = CmxIrohBidirectionalStream( + receiveStream: TestHangingIrohReceiveStream(), + sendStream: TestIrohSendStream() + ) + let hangingConnection = TestIrohConnection( + remoteIdentity: remoteIdentity, + bidirectionalStreams: [hangingControl] + ) + let goodConnection = TestIrohConnection( + remoteIdentity: remoteIdentity, + bidirectionalStreams: [answeringControlStream()] + ) + let endpoint = TestDialingIrohEndpoint( + localIdentity: localIdentity, + dialResults: [ + .connection(hangingConnection), + .connection(goodConnection), + ] + ) + let session = try CmxIrohClientSession( + endpoint: endpoint, + targetIdentity: remoteIdentity, + dialPlan: try testIrohDialPlan(publicPaths: [try publicRelayHint()]), + credential: credential, + dialPhaseTimeout: .milliseconds(40) + ) + + let failure = await boundedConnectFailure(session, within: .seconds(2)) + #expect(failure as? CmxIrohClientSessionError == .dialTimedOut) + + // The timed-out attempt must have been retired cleanly: the very next + // attempt on the same session dials again and admits. + try await session.connect() + + #expect(await endpoint.observedDialedAddresses().count == 2) + #expect(await hangingConnection.observedCloseCallCount() >= 1) + await session.close() + } + + @Test("the dial bound holds when the stalled phase ignores cancellation") + func dialBoundHoldsWhenTheStalledPhaseIgnoresCancellation() async throws { + let receiveStream = TestUncancellableIrohReceiveStream() + let control = CmxIrohBidirectionalStream( + receiveStream: receiveStream, + sendStream: TestIrohSendStream() + ) + let connection = TestIrohConnection( + remoteIdentity: remoteIdentity, + bidirectionalStreams: [control] + ) + // Model the transport contract of the FFI driver: closing the QUIC + // connection terminates the pending read; Swift task cancellation + // alone does nothing (the bindings poll a Rust future that never + // observes it). + let closeUnblocksReads = Task { + await connection.waitUntilClosed() + await receiveStream.failPendingReceives() + } + defer { closeUnblocksReads.cancel() } + let endpoint = TestDialingIrohEndpoint( + localIdentity: localIdentity, + dialResults: [.connection(connection)] + ) + let session = try CmxIrohClientSession( + endpoint: endpoint, + targetIdentity: remoteIdentity, + dialPlan: try testIrohDialPlan(publicPaths: [try publicRelayHint()]), + credential: credential, + dialPhaseTimeout: .milliseconds(40) + ) + + // Observe without cancelling: the deadline must be enforced by the + // session itself, at the transport boundary, not by this test's + // cooperative cancellation. + let connectTask = Task { try await session.connect() } + let outcome = await observedOutcome(of: connectTask, within: .seconds(2)) + #expect(outcome == .failed(.dialTimedOut)) + #expect(await connection.observedCloseCallCount() >= 1) + + // Drain a still-wedged attempt (the red state) so no orphaned task + // outlives the test. + await connection.close(errorCode: 0, reason: "test_cleanup") + await session.close() + } + + // MARK: - Support + + private enum ObservedDialOutcome: Equatable { + case succeeded + case failed(CmxIrohClientSessionError) + case failedOther(String) + case stillRunningAtObservationDeadline + } + + private actor ObservedDialOutcomeBox { + private var outcome: ObservedDialOutcome? + + func record(_ value: ObservedDialOutcome) { + outcome = value + } + + func current() -> ObservedDialOutcome? { + outcome + } + } + + /// Waits for `connectTask` without ever cancelling it, so a deadline that + /// only works through cooperative cancellation cannot pass by accident. + /// The monitor is deliberately unstructured: in the red state the connect + /// attempt is wedged, and a structured wait on it would deadlock this + /// test; the caller's cleanup close drains it after observation. + private func observedOutcome( + of connectTask: Task, + within limit: Duration + ) async -> ObservedDialOutcome { + let box = ObservedDialOutcomeBox() + Task { + do { + try await connectTask.value + await box.record(.succeeded) + } catch let error as CmxIrohClientSessionError { + await box.record(.failed(error)) + } catch { + await box.record(.failedOther(String(describing: error))) + } + } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: limit) + while clock.now < deadline { + if let outcome = await box.current() { return outcome } + try? await clock.sleep(for: .milliseconds(10)) + } + return await box.current() ?? .stillRunningAtObservationDeadline + } + + /// Runs `connect()` under a test watchdog so the red state (an unbounded + /// admission hang) fails this test quickly instead of hanging the suite. + private func boundedConnectFailure( + _ session: CmxIrohClientSession, + within limit: Duration + ) async -> (any Error)? { + let connectTask = Task { try await session.connect() } + let watchdog = Task { + try? await ContinuousClock().sleep(for: limit) + connectTask.cancel() + } + defer { watchdog.cancel() } + do { + _ = try await connectTask.value + return nil + } catch { + return error + } + } + + private func answeringControlStream() -> CmxIrohBidirectionalStream { + let codec = CmxIrohAdmissionAckCodec() + let accepted = codec.encodeFrame(.acceptedPendingNatTraversal) + let serverReady = codec.encodeFrame(.serverReady) + return CmxIrohBidirectionalStream( + receiveStream: TestIrohReceiveStream(buffer: accepted + serverReady), + sendStream: TestIrohSendStream() + ) + } + + private func publicRelayHint() throws -> CmxIrohPathHint { + try CmxIrohPathHint( + kind: .relayURL, + value: "https://use1-1.relay.lawrence.cmux.iroh.link/", + source: .native, + privacyScope: .publicInternet + ) + } +} + +/// A control stream that never yields admission bytes. The hang is +/// cancellable, mirroring the production stream contract the phase bound +/// relies on (`TestIrohDialResult.hang` models the same shape for dials). +actor TestHangingIrohReceiveStream: CmxIrohReceiveStream { + private var stoppedCodes: [UInt64] = [] + + func receive(maximumByteCount _: Int) async throws -> Data? { + try await Task.sleep(for: .seconds(3_600)) + return nil + } + + func stop(errorCode: UInt64) { + stoppedCodes.append(errorCode) + } + + func observedStoppedCodes() -> [UInt64] { + stoppedCodes + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift index d748e8563c72..f7418021d49e 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift @@ -17,50 +17,24 @@ struct CmxIrohConfigurationTests { } @Test - func relayCredentialRequiresCanonicalURLTokenAndFutureRefresh() throws { - #expect(throws: CmxIrohRelayConfigurationError.invalidURL) { - try relay(url: "http://relay.example/", token: "aaaa") - } - #expect(throws: CmxIrohRelayConfigurationError.invalidURL) { - try relay(url: "https://relay.example", token: "aaaa") - } - #expect(throws: CmxIrohRelayConfigurationError.invalidToken) { - try relay(url: "https://relay.example/", token: "upperCASE") - } - #expect( - try relay(url: "https://relay.example/", token: "aB_-.cD-_.eF_-").token - == "aB_-.cD-_.eF_-" - ) - #expect(throws: CmxIrohRelayConfigurationError.invalidLifetime) { - try CmxIrohRelayConfiguration( - url: "https://relay.example/", - token: "aaaa", - expiresAt: now.addingTimeInterval(10), - refreshAfter: now, - now: now - ) - } - } - - @Test - func endpointConfigurationRejectsUnmanagedAndDuplicateRelays() throws { - let relay = try relay(url: "https://relay.example/", token: "aaaa") + func managedEndpointConfigurationIsTokenlessAndBoundedBySize() throws { let secret = try CmxIrohSecretKey(bytes: Data(repeating: 0, count: 32)) + let configuration = try CmxIrohEndpointConfiguration( + secretKey: secret, + alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], + managedRelayURLs: ["https://relay.example/"] + ) + #expect(configuration.relayProfile.activeRelays.map(\.url) == ["https://relay.example/"]) + #expect(configuration.relayProfile.activeRelays.allSatisfy { + $0.authenticationToken == nil + }) - #expect(throws: CmxIrohEndpointConfigurationError.unmanagedRelayURL(relay.url)) { + let oversized = Set((0 ..< 17).map { "https://relay\($0).example/" }) + #expect(throws: CmxIrohEndpointConfigurationError.tooManyRelays(oversized.count)) { try CmxIrohEndpointConfiguration( secretKey: secret, alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [relay] - ) - } - #expect(throws: CmxIrohEndpointConfigurationError.duplicateRelayURL(relay.url)) { - try CmxIrohEndpointConfiguration( - secretKey: secret, - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [relay.url], - relays: [relay, relay] + managedRelayURLs: oversized ) } } @@ -84,7 +58,6 @@ struct CmxIrohConfigurationTests { #expect(configuration.relayProfile.allowedRelayURLs == [custom.relays[0].url]) #expect(configuration.managedRelayURLs.isEmpty) - #expect(configuration.relays.isEmpty) } @Test @@ -93,8 +66,7 @@ struct CmxIrohConfigurationTests { let defaultConfiguration = try CmxIrohEndpointConfiguration( secretKey: secret, alpns: [], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) #expect(defaultConfiguration.bindPolicy == .ephemeral) #expect(defaultConfiguration.bindPolicy.socketAddress == nil) @@ -126,16 +98,4 @@ struct CmxIrohConfigurationTests { } } - private func relay( - url: String, - token: String - ) throws -> CmxIrohRelayConfiguration { - try CmxIrohRelayConfiguration( - url: url, - token: token, - expiresAt: now.addingTimeInterval(24 * 60 * 60), - refreshAfter: now.addingTimeInterval(12 * 60 * 60), - now: now - ) - } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift index 4a23d62cc118..44d7dde4cc35 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift @@ -35,6 +35,7 @@ enum CmxIrohCustomRelayLiveEnvironment { static var hasBrokerCredentials: Bool { [ "CMUX_IROH_CUSTOM_RELAY_BROKER_URL", + "CMUX_IROH_CUSTOM_RELAY_BROKER_RELAY_URL", "CMUX_IROH_CUSTOM_RELAY_ACCESS_TOKEN", "CMUX_IROH_CUSTOM_RELAY_REFRESH_TOKEN", ].allSatisfy { environment[$0]?.isEmpty == false } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift index 6d67c7b8aff4..bf44cdb4b0a6 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift @@ -121,7 +121,7 @@ struct CmxIrohCustomRelayLiveTests { } @Test(.enabled(if: CmxIrohCustomRelayLiveEnvironment.hasBrokerCredentials)) - func brokerBoundTokensCarryBidirectionalRoundTrip() async throws { + func brokerRegisteredEndpointsCarryTokenlessRoundTrip() async throws { let baseURL = try #require(URL(string: try CmxIrohCustomRelayLiveEnvironment.required( "CMUX_IROH_CUSTOM_RELAY_BROKER_URL" ))) @@ -166,40 +166,18 @@ struct CmxIrohCustomRelayLiveTests { ) bindingIDs.append(second.bindingID) - stage = "mint first endpoint-bound token" - print("Iroh live relay: \(stage)") - let firstToken = try await broker.issueRelayToken( - bindingID: first.bindingID, - endpointID: first.endpointID - ) - stage = "mint second endpoint-bound token" - print("Iroh live relay: \(stage)") - let secondToken = try await broker.issueRelayToken( - bindingID: second.bindingID, - endpointID: second.endpointID - ) - let firstCredentials = Dictionary( - firstToken.credentials.map { ($0.relayURL, $0.token) }, - uniquingKeysWith: { _, latestToken in latestToken } - ) - let commonRelayURL = try #require( - secondToken.credentials.lazy - .map(\.relayURL) - .first(where: { firstCredentials[$0] != nil }) - ) - let firstAuthenticationToken = try #require(firstCredentials[commonRelayURL]) - let secondAuthenticationToken = try #require( - secondToken.credentials.first(where: { - $0.relayURL == commonRelayURL - })?.token + let relayURL = try CmxIrohCustomRelayLiveEnvironment.required( + "CMUX_IROH_CUSTOM_RELAY_BROKER_RELAY_URL" ) - stage = "carry bidirectional relay-only stream" + // Tokenless connect: the relay's allow hook admits the registered + // endpoint keys proven in the handshake; no credential is attached. + stage = "carry tokenless bidirectional relay-only stream" print("Iroh live relay: \(stage)") try await assertBidirectionalRoundTrip( - relayURL: commonRelayURL, - firstAuthenticationToken: firstAuthenticationToken, - secondAuthenticationToken: secondAuthenticationToken, + relayURL: relayURL, + firstAuthenticationToken: nil, + secondAuthenticationToken: nil, firstSecretKey: firstSecretKey, secondSecretKey: secondSecretKey ) @@ -442,7 +420,8 @@ struct CmxIrohCustomRelayLiveTests { to: secondAddress, alpn: alpn ) - let incomingConnection = try #require(await acceptedConnection) + let incomingAttempt = try #require(await acceptedConnection) + let incomingConnection = try await incomingAttempt.establish() return ConnectionPair( outgoing: outgoingConnection, incoming: incomingConnection diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift index 005f34e90ff9..e696418cd427 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift @@ -45,8 +45,7 @@ struct CmxIrohCustomRelayProbeTests { endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] ) let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: fixture.configuration.managedRelayURLs, - relays: [] + managedRelayURLs: fixture.configuration.managedRelayURLs ) let result = await CmxIrohCustomRelayProbe(factory: factory).probe( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift index 3b26153fbf2f..7d0242ffb956 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift @@ -12,29 +12,22 @@ struct CmxIrohCustomRelayRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), now: { fixture.now } ) try await runtime.start() - try await Self.waitForRelayMutation(endpoint) - let initialCredentialUpdates = await endpoint.observedRelayUpdates().count let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count try await runtime.replaceRelayPolicy(try Self.managedPolicy( - response: fixture.relayResponse(), - relayURLs: Set(ClientRuntimeTestFixture.relayURLs), - now: fixture.now + relayURLs: Set(ClientRuntimeTestFixture.relayURLs) )) - let credentialUpdates = await endpoint.observedRelayUpdates().count - - initialCredentialUpdates let profileUpdates = await endpoint.observedRelayProfileUpdates().count - initialProfileUpdates - #expect(credentialUpdates + profileUpdates == 1) + #expect(profileUpdates == 1) #expect(await endpoint.observedCloseCallCount() == 0) #expect(await runtime.snapshot().endpointID == fixture.endpointID) await runtime.stop() @@ -56,61 +49,49 @@ struct CmxIrohCustomRelayRuntimeTests { handleTransport: { session, _ in await session.close() } ) try await runtime.start() - try await Self.waitForRelayMutation(endpoint) - let initialCredentialUpdates = await endpoint.observedRelayUpdates().count let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count - let response = try ClientRuntimeTestFixture().relayResponse() try await runtime.replaceRelayPolicy(try Self.managedPolicy( - response: response, - relayURLs: fixture.managedRelays, - now: Date(timeIntervalSince1970: 1_800_000_000) + relayURLs: fixture.managedRelays )) - let credentialUpdates = await endpoint.observedRelayUpdates().count - - initialCredentialUpdates let profileUpdates = await endpoint.observedRelayProfileUpdates().count - initialProfileUpdates - #expect(credentialUpdates + profileUpdates == 1) + #expect(profileUpdates == 1) #expect(await endpoint.observedCloseCallCount() == 0) #expect(await runtime.snapshot().endpointID == fixture.endpointID) await runtime.stop() } @Test - func clientManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws { + func clientManagedPolicyFailureLeavesEndpointOpen() async throws { let fixture = try ClientRuntimeTestFixture() let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let runtime = try CmxIrohClientRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: fixture.configuration, pendingRevocations: fixture.pendingRevocations(), now: { fixture.now } ) try await runtime.start() - try await Self.waitForRelayMutation(endpoint) await endpoint.setRelayUpdateShouldFail(true) await #expect(throws: TestIrohTransportError.relayUpdateFailed) { try await runtime.replaceRelayPolicy(try Self.managedPolicy( - response: fixture.relayResponse(), - relayURLs: Set(ClientRuntimeTestFixture.relayURLs), - now: fixture.now + relayURLs: Set(ClientRuntimeTestFixture.relayURLs) )) } - #expect(await runtime.relayCoordinator == nil) #expect(await endpoint.observedCloseCallCount() == 0) await runtime.stop() } @Test - func hostManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws { + func hostManagedPolicyFailureLeavesEndpointOpen() async throws { let fixture = try HostRuntimeFixture() let endpoint = TestIrohEndpoint(identity: fixture.endpointID) let runtime = CmxIrohHostRuntime( @@ -125,25 +106,20 @@ struct CmxIrohCustomRelayRuntimeTests { handleTransport: { session, _ in await session.close() } ) try await runtime.start() - try await Self.waitForRelayMutation(endpoint) await endpoint.setRelayUpdateShouldFail(true) - let response = try ClientRuntimeTestFixture().relayResponse() await #expect(throws: TestIrohTransportError.relayUpdateFailed) { try await runtime.replaceRelayPolicy(try Self.managedPolicy( - response: response, - relayURLs: fixture.managedRelays, - now: Date(timeIntervalSince1970: 1_800_000_000) + relayURLs: fixture.managedRelays )) } - #expect(await runtime.relayCoordinator == nil) #expect(await endpoint.observedCloseCallCount() == 0) await runtime.stop() } @Test - func clientOverrideSkipsManagedTokenIssuance() async throws { + func clientOverrideInstallsCustomProfileAtBind() async throws { let fixture = try ClientRuntimeTestFixture() let custom = try CmxIrohCustomRelayProfile( relays: [CmxIrohCustomRelay(url: "https://private.example.net:8443/")] @@ -165,8 +141,7 @@ struct CmxIrohCustomRelayRuntimeTests { let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ) let runtime = try CmxIrohClientRuntime( factory: factory, @@ -179,14 +154,13 @@ struct CmxIrohCustomRelayRuntimeTests { try await runtime.start() #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) - #expect(await endpoint.observedRelayUpdates().isEmpty) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) #expect(await factory.observedConfigurations().first?.relayProfile == profile) await runtime.stop() } @Test - func hostOverrideSkipsManagedTokenIssuance() async throws { + func hostOverrideInstallsCustomProfileAtBind() async throws { let fixture = try HostRuntimeFixture() let custom = try CmxIrohCustomRelayProfile( relays: [CmxIrohCustomRelay(url: "https://private.example.net:8443/")] @@ -209,7 +183,6 @@ struct CmxIrohCustomRelayRuntimeTests { try await runtime.start() #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) #expect(await factory.observedConfigurations().first?.relayProfile == profile) await runtime.stop() } @@ -244,8 +217,7 @@ struct CmxIrohCustomRelayRuntimeTests { factory: TestIrohEndpointFactory(endpoints: [endpoint]), broker: TestIrohClientBroker( binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() + discovery: fixture.discovery ), configuration: configuration, pendingRevocations: fixture.pendingRevocations(), @@ -296,14 +268,9 @@ struct CmxIrohCustomRelayRuntimeTests { } private static func managedPolicy( - response: CmxIrohRelayTokenResponse, - relayURLs: Set, - now: Date + relayURLs: Set ) throws -> CmxIrohEffectiveRelayPolicy { - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: relayURLs, - relays: response.relayConfigurations(now: now) - ) + let profile = try CmxIrohEndpointRelayProfile(managedRelayURLs: relayURLs) return CmxIrohEffectiveRelayPolicy( endpointRelayProfile: profile, managedSnapshot: nil, @@ -312,27 +279,7 @@ struct CmxIrohCustomRelayRuntimeTests { effectivePreference: .automatic, source: .managed, usedCachedPolicy: false, - preferenceRevision: nil, - relayBootstrap: response + preferenceRevision: nil ) } - - private static func waitForRelayMutation(_ endpoint: TestIrohEndpoint) async throws { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .seconds(1)) - while clock.now < deadline { - let credentialUpdates = await endpoint.observedRelayUpdates().count - let profileUpdates = await endpoint.observedRelayProfileUpdates().count - if credentialUpdates + profileUpdates > 0 { return } - await Task.yield() - } - let credentialUpdates = await endpoint.observedRelayUpdates().count - let profileUpdates = await endpoint.observedRelayProfileUpdates().count - let counts = "credential updates: \(credentialUpdates), " - + "profile updates: \(profileUpdates)" - Issue.record("Timed out waiting for relay mutation (\(counts))") - throw RelayMutationTimeout() - } } - -private struct RelayMutationTimeout: Error {} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift new file mode 100644 index 000000000000..af77f9c6d67c --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDebugRelayOverrideTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing +@testable import CmuxIrohTransport + +@Suite struct CmxIrohDebugRelayOverrideTests { + @Test func parsesCanonicalHTTPSOriginAndAddsTrailingSlash() throws { + let profile = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: " https://relay-test.example.com ") + ) + #expect(profile.allowedRelayURLs == ["https://relay-test.example.com/"]) + #expect(profile.source == .custom) + #expect(profile.activeRelays.map(\.url) == ["https://relay-test.example.com/"]) + } + + @Test func keepsExplicitPort() throws { + let profile = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com:8443/") + ) + #expect(profile.allowedRelayURLs == ["https://relay-test.example.com:8443/"]) + } + + @Test(arguments: [ + nil, + "", + " ", + "http://insecure.example.com/", + "https://relay.example.com/path", + "https://relay.example.com/?q=1", + "https://user:pw@relay.example.com/", + "not a url", + ] as [String?]) + func rejectsUnusableValues(_ raw: String?) { + #expect(CmxIrohDebugRelayOverride.profile(rawValue: raw) == nil) + } + + @Test func environmentWinsOverDefaults() throws { + let suiteName = "cmux-debug-relay-override-tests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set( + "https://from-defaults.example.com/", + forKey: CmxIrohDebugRelayOverride.key + ) + #expect( + CmxIrohDebugRelayOverride.rawValue( + environment: [CmxIrohDebugRelayOverride.key: "https://from-env.example.com/"], + defaults: defaults + ) == "https://from-env.example.com/" + ) + #expect( + CmxIrohDebugRelayOverride.rawValue( + environment: [:], + defaults: defaults + ) == "https://from-defaults.example.com/" + ) + } + + @Test func hostResolutionPrefersOverride() throws { + let fixture = try HostRuntimeFixture() + let override = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") + ) + let resolved = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: override) + #expect(resolved == override) + + let managed = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: nil) + #expect(managed.source == .managed) + #expect(managed.allowedRelayURLs == fixture.managedRelays) + } + + /// A host without a verifiable cached policy is configured with the + /// relay-less `.unavailableManagedSelection` placeholder. The debug + /// override must still win at bind time, or the endpoint binds with zero + /// relays and can never dial the forced test relay. The override is a + /// custom profile and custom relays are exempt from the + /// withhold-until-registered ordering, so it stays installed at bind. + @Test func overrideWinsOverUnavailableManagedSelectionAtBind() async throws { + let fixture = try HostRuntimeFixture() + let override = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let runtime = CmxIrohHostRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration( + endpointRelayProfile: .unavailableManagedSelection + ), + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() } + ) + + try await runtime.start(debugRelayOverride: override) + + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect(boundConfigurations.first?.relayProfile == override) + // Installed at bind: no post-registration relay swap replaces it. + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// Without the override, a host configured with + /// `.unavailableManagedSelection` still binds with zero relays, + /// preserving the withhold-managed-relays-until-registered ordering + /// (manaflow-ai/cmux#10867): no managed relay may be dialable before + /// broker admission. + @Test func withoutOverrideUnavailableManagedSelectionBindsEmpty() async throws { + let fixture = try HostRuntimeFixture() + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let runtime = CmxIrohHostRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration( + endpointRelayProfile: .unavailableManagedSelection + ), + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() } + ) + + try await runtime.start(debugRelayOverride: nil) + + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect(boundConfigurations.first?.relayProfile.activeRelays.isEmpty == true) + #expect(boundConfigurations.first?.relayProfile.allowedRelayURLs.isEmpty == true) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + @Test func clientResolutionPrefersOverride() throws { + let fixture = try ClientRuntimeTestFixture() + let override = try #require( + CmxIrohDebugRelayOverride.profile(rawValue: "https://relay-test.example.com/") + ) + let resolved = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: override) + #expect(resolved == override) + + let managed = try fixture.configuration.resolvedEndpointRelayProfile(debugOverride: nil) + #expect(managed.source == .managed) + #expect(managed.allowedRelayURLs == Set(ClientRuntimeTestFixture.relayURLs)) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift index 6e7cb8d2fd6e..332cf28c4f33 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift @@ -86,8 +86,7 @@ struct CmxIrohDirectTransportGateTests { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: secretByte, count: 32)), alpns: [alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) let options = CmxIrohLibEndpointFactory.endpointOptions( configuration: configuration, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift new file mode 100644 index 000000000000..24a3cc0560a9 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerCapacityReleaseTests.swift @@ -0,0 +1,450 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Admission capacity must be tied to connection liveness, not to the idle +/// timer alone. A client that dies abnormally (no clean close) leaves a dead +/// connection whose handler never returns; the same TLS-authenticated identity +/// must still be admitted promptly on redial, and a transport-reported close +/// must release the slot without waiting for the handler to unwind. Strangers +/// can never preempt another identity's capacity. +@Suite +struct CmxIrohEndpointServerCapacityReleaseTests { + private enum ReplacementOutcome: Sendable { + case predecessorClosed(code: UInt64, reason: String) + case redialClosed(code: UInt64, reason: String) + } + + /// Waits for the one deterministic signal that distinguishes the fixed + /// behavior (the dead predecessor is superseded) from the defect (the + /// redial itself is refused and closed). + private static func firstReplacementOutcome( + predecessor: TestIrohConnection, + redial: TestIrohConnection + ) async -> ReplacementOutcome { + await withTaskGroup(of: ReplacementOutcome.self) { group in + group.addTask { + var closes = await predecessor.closeEvents().makeAsyncIterator() + let close = await closes.next() + return .predecessorClosed( + code: close?.code ?? 0, + reason: close?.reason ?? "stream_ended" + ) + } + group.addTask { + var closes = await redial.closeEvents().makeAsyncIterator() + let close = await closes.next() + return .redialClosed( + code: close?.code ?? 0, + reason: close?.reason ?? "stream_ended" + ) + } + let first = await group.next() + group.cancelAll() + return first ?? .redialClosed(code: 0, reason: "no_outcome") + } + } + + private static func makeSupervisor( + endpoint: TestAcceptingIrohEndpoint, + keyByte: UInt8 + ) throws -> CmxIrohEndpointSupervisor { + CmxIrohEndpointSupervisor( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + configuration: try CmxIrohEndpointConfiguration( + secretKey: CmxIrohSecretKey(bytes: Data(repeating: keyByte, count: 32)), + alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], + managedRelayURLs: [] + ) + ) + } + + /// Records each admission-marker result so tests can await the exact + /// authenticate step of one connection deterministically. + private actor AdmissionMarkerOutcomeRecorder { + typealias Outcome = (identity: CmxIrohPeerIdentity, admitted: Bool) + private var outcomes: [Outcome] = [] + private var waiters: [CheckedContinuation] = [] + + func record(identity: CmxIrohPeerIdentity, admitted: Bool) { + let outcome = (identity, admitted) + if waiters.isEmpty { + outcomes.append(outcome) + } else { + waiters.removeFirst().resume(returning: outcome) + } + } + + func next() async -> Outcome { + if !outcomes.isEmpty { return outcomes.removeFirst() } + return await withCheckedContinuation { waiters.append($0) } + } + } + + @Test + func sameIdentityRedialAfterAbnormalPeerDeathIsAdmittedPromptly() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "1", count: 64) + ) + let clientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "2", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 11) + _ = try await supervisor.activate() + let blocker = EndpointServerHandlerBlocker() + let recorder = EndpointServerRecorder() + // The identity is at its full capacity with one usable session, the + // worst case an abnormal client death leaves behind. + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 1, + maximumConnectionsPerIdentity: 1 + ) { connection, generation, admission in + #expect(await admission()) + #expect(await admission.markUsable()) + // Recording after promotion makes each recorded event mean "this + // session is fully admitted AND usable", which removes ordering + // races between a predecessor's promotion and the next dial. + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + // The dead peer never closes cleanly: its handler stays parked + // exactly like a session read against a silently dead QUIC peer. + await blocker.wait() + } + let deadPredecessor = TestIrohConnection( + remoteIdentity: clientIdentity, + bidirectionalStreams: [] + ) + let redial = TestIrohConnection( + remoteIdentity: clientIdentity, + bidirectionalStreams: [] + ) + + await server.start() + await endpoint.enqueue(deadPredecessor) + #expect(await recorder.next().identity == clientIdentity) + + // The client process died abnormally; no close arrives. The SAME + // identity redials and must be admitted before any idle timeout. + await endpoint.enqueue(redial) + let outcome = await Self.firstReplacementOutcome( + predecessor: deadPredecessor, + redial: redial + ) + switch outcome { + case let .predecessorClosed(_, reason): + #expect(reason == "superseded_connection") + case let .redialClosed(_, reason): + Issue.record( + "same-identity redial was refused (\(reason)) instead of replacing its dead predecessor" + ) + } + #expect(await recorder.recordedCount() == 2) + #expect(await redial.observedCloseCallCount() == 0) + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } + + @Test + func deadPredecessorHoldingAGlobalSlotDoesNotRefuseItsOwnIdentitysRedial() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "3", count: 64) + ) + let deadClientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "4", count: 64) + ) + let liveClientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "5", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 12) + _ = try await supervisor.activate() + let blocker = EndpointServerHandlerBlocker() + let recorder = EndpointServerRecorder() + // The global pool is full: one dead session plus one live session + // belonging to another identity. + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 2, + maximumConnectionsPerIdentity: 2 + ) { connection, generation, admission in + #expect(await admission()) + #expect(await admission.markUsable()) + // Recording after promotion makes each recorded event mean "this + // session is fully admitted AND usable", which removes ordering + // races between a predecessor's promotion and the next dial. + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + await blocker.wait() + } + let deadPredecessor = TestIrohConnection( + remoteIdentity: deadClientIdentity, + bidirectionalStreams: [] + ) + let liveBystander = TestIrohConnection( + remoteIdentity: liveClientIdentity, + bidirectionalStreams: [] + ) + let redial = TestIrohConnection( + remoteIdentity: deadClientIdentity, + bidirectionalStreams: [] + ) + + await server.start() + await endpoint.enqueue(deadPredecessor) + #expect(await recorder.next().identity == deadClientIdentity) + await endpoint.enqueue(liveBystander) + #expect(await recorder.next().identity == liveClientIdentity) + + await endpoint.enqueue(redial) + let outcome = await Self.firstReplacementOutcome( + predecessor: deadPredecessor, + redial: redial + ) + switch outcome { + case let .predecessorClosed(_, reason): + #expect(reason == "superseded_connection") + case let .redialClosed(_, reason): + Issue.record( + "same-identity redial was refused (\(reason)) while its own dead predecessor held the global slot" + ) + } + #expect(await recorder.recordedCount() == 3) + #expect(await redial.observedCloseCallCount() == 0) + // Replacing your own dead predecessor must never disturb another + // identity's live session. + #expect(await liveBystander.observedCloseCallCount() == 0) + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } + + @Test + func differentIdentityCannotPreemptAnotherIdentitysSlot() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "6", count: 64) + ) + let clientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "7", count: 64) + ) + let strangerIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "8", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 13) + _ = try await supervisor.activate() + let blocker = EndpointServerHandlerBlocker() + let recorder = EndpointServerRecorder() + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 1, + maximumConnectionsPerIdentity: 1 + ) { connection, generation, admission in + #expect(await admission()) + #expect(await admission.markUsable()) + // Recording after promotion makes each recorded event mean "this + // session is fully admitted AND usable", which removes ordering + // races between a predecessor's promotion and the next dial. + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + await blocker.wait() + } + let occupant = TestIrohConnection( + remoteIdentity: clientIdentity, + bidirectionalStreams: [] + ) + let stranger = TestIrohConnection( + remoteIdentity: strangerIdentity, + bidirectionalStreams: [] + ) + var strangerCloses = await stranger.closeEvents().makeAsyncIterator() + + await server.start() + await endpoint.enqueue(occupant) + #expect(await recorder.next().identity == clientIdentity) + + // A different, fully authenticated identity dials into the full + // server: it must be refused, and the occupant must keep its slot. + await endpoint.enqueue(stranger) + let close = try #require(await strangerCloses.next()) + #expect(close.reason == "connection_capacity") + #expect(await occupant.observedCloseCallCount() == 0) + #expect(await recorder.recordedCount() == 1) + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } + + @Test + func capacityFilledDuringAdmissionClosesTheUnplaceableConnection() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "e", count: 64) + ) + let occupantIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "f", count: 64) + ) + let strandedIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "0", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 15) + _ = try await supervisor.activate() + let strandedGate = EndpointServerHandlerBlocker() + let blocker = EndpointServerHandlerBlocker() + let established = EndpointServerRecorder() + let outcomes = AdmissionMarkerOutcomeRecorder() + // Global capacity 2. The stranded identity passes registerEstablished + // while one slot is still free, then both slots fill before its + // handler calls the admission marker. + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 2, + maximumConnectionsPerIdentity: 2 + ) { connection, generation, admission in + let identity = await connection.remoteIdentity() + await established.record(identity: identity, generation: generation) + if identity == strandedIdentity { + await strandedGate.wait() + } + await outcomes.record( + identity: identity, + admitted: await admission() + ) + await blocker.wait() + } + let occupant = TestIrohConnection( + remoteIdentity: occupantIdentity, + bidirectionalStreams: [] + ) + let stranded = TestIrohConnection( + remoteIdentity: strandedIdentity, + bidirectionalStreams: [] + ) + let occupantRedial = TestIrohConnection( + remoteIdentity: occupantIdentity, + bidirectionalStreams: [] + ) + + await server.start() + await endpoint.enqueue(occupant) + _ = await established.next() + _ = await outcomes.next() + + // The stranded connection completes its handshake and passes the + // registerEstablished capacity check (one global slot is free), then + // parks before authenticating. + await endpoint.enqueue(stranded) + #expect(await established.next().identity == strandedIdentity) + + // The occupant's same-identity redial reserves the replacement slot + // and is admitted, filling global capacity while the stranded + // admission is still parked. + await endpoint.enqueue(occupantRedial) + #expect(await established.next().identity == occupantIdentity) + let redialOutcome = await outcomes.next() + #expect(redialOutcome.admitted) + + // The stranded admission now finds capacity full with nothing of its + // own to replace. Refusal is correct, but the server must close the + // connection it still owns instead of orphaning it outside every + // capacity table. + await strandedGate.releaseAll() + let strandedOutcome = await outcomes.next() + #expect(strandedOutcome.identity == strandedIdentity) + #expect(!strandedOutcome.admitted) + #expect(await stranded.observedCloseCallCount() == 1) + var strandedCloses = await stranded.closeEvents().makeAsyncIterator() + if await stranded.observedCloseCallCount() == 1 { + let close = await strandedCloses.next() + #expect(close?.reason == "connection_capacity") + } + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } + + @Test + func transportReportedCloseReleasesTheSlotWithoutWaitingForTheHandler() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "9", count: 64) + ) + let clientIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "a", count: 64) + ) + let newcomerIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "b", count: 64) + ) + let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) + let supervisor = try Self.makeSupervisor(endpoint: endpoint, keyByte: 14) + _ = try await supervisor.activate() + let blocker = EndpointServerHandlerBlocker() + let recorder = EndpointServerRecorder() + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumConnections: 1, + maximumConnectionsPerIdentity: 1 + ) { connection, generation, admission in + #expect(await admission()) + #expect(await admission.markUsable()) + // Recording after promotion makes each recorded event mean "this + // session is fully admitted AND usable", which removes ordering + // races between a predecessor's promotion and the next dial. + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + // The handler never unwinds on its own, like a serve loop that has + // not yet observed the failed connection. + await blocker.wait() + } + let occupant = TestIrohConnection( + remoteIdentity: clientIdentity, + bidirectionalStreams: [] + ) + let newcomer = TestIrohConnection( + remoteIdentity: newcomerIdentity, + bidirectionalStreams: [] + ) + + await server.start() + await endpoint.enqueue(occupant) + #expect(await recorder.next().identity == clientIdentity) + + // The transport reports the connection terminal (reset, error, or its + // own timeout). The slot must be released on that signal immediately, + // not when the parked handler eventually returns. + await occupant.close(errorCode: 0, reason: "transport_reported_loss") + + await endpoint.enqueue(newcomer) + // Deterministic either way: the fix admits the newcomer (a second + // recorded admission), the defect closes it "connection_capacity". + for _ in 0 ..< 1000 { + let admittedCount = await recorder.recordedCount() + let newcomerCloseCount = await newcomer.observedCloseCallCount() + if admittedCount == 2 || newcomerCloseCount > 0 { break } + await Task.yield() + } + #expect(await recorder.recordedCount() == 2) + #expect(await newcomer.observedCloseCallCount() == 0) + if await recorder.recordedCount() == 2 { + #expect(await recorder.next().identity == newcomerIdentity) + } + + await blocker.releaseAll() + await server.stop() + await supervisor.deactivate() + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift new file mode 100644 index 000000000000..fc412a49978c --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerStalledHandshakeTests.swift @@ -0,0 +1,269 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrohTransport + +/// Regression coverage for the Mac-host relay wedge: a peer that initiates a +/// connection and then stops making handshake progress (killed client, dead +/// relay path) must never gate other peers' admissions. +/// +/// The 2026-08-26 itest timing batch showed the live failure shape: after one +/// abnormal client death, the host stayed broker-registered and relay-attached +/// (its relay TCP connection was unchanged on the relay's side), yet every new +/// dial timed out until the host app was restarted. The host's accept pipeline +/// performed the entire server-side handshake inline in the one serial accept +/// loop, so a single stalled handshake blocked every subsequent admission. +@Suite +struct CmxIrohEndpointServerStalledHandshakeTests { + @Test + func aPeerStalledMidHandshakeDoesNotBlockOtherAdmissions() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "c", count: 64) + ) + let healthyIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "d", count: 64) + ) + let endpoint = StalledHandshakeIrohEndpoint(identity: localIdentity) + let supervisor = CmxIrohEndpointSupervisor( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + configuration: try CmxIrohEndpointConfiguration( + secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)), + alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], + managedRelayURLs: [] + ) + ) + _ = try await supervisor.activate() + let recorder = EndpointServerRecorder() + let server = CmxIrohEndpointServer(supervisor: supervisor) { connection, generation, _ in + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + await connection.close(errorCode: 0, reason: "test_complete") + } + + await server.start() + // One connection attempt whose server-side handshake never completes, + // followed by a healthy peer waiting behind it. + await endpoint.enqueueStalledHandshake() + await endpoint.enqueue( + TestIrohConnection( + remoteIdentity: healthyIdentity, + bidirectionalStreams: [] + ) + ) + + var admittedCount = 0 + for _ in 0 ..< 200 { + admittedCount = await recorder.recordedCount() + if admittedCount > 0 { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(admittedCount == 1) + if admittedCount == 1 { + let admitted = await recorder.next() + #expect(admitted.identity == healthyIdentity) + } + + await endpoint.releaseStalledHandshakes() + await server.stop() + await supervisor.deactivate() + } + + /// A timed-out handshake cannot be aborted once the native attempt is + /// consumed (task cancellation does not reach the driver), so its + /// admission slot must stay occupied until the attempt itself resolves. + /// Releasing the slot at the admission deadline lets a remote peer mint + /// more live handshake work than `maximumPendingAdmissions` permits. + @Test + func timedOutHandshakeKeepsItsSlotUntilTheAttemptResolves() async throws { + let localIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "e", count: 64) + ) + let healthyIdentity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "f", count: 64) + ) + let endpoint = StalledHandshakeIrohEndpoint(identity: localIdentity) + let supervisor = CmxIrohEndpointSupervisor( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + configuration: try CmxIrohEndpointConfiguration( + secretKey: CmxIrohSecretKey(bytes: Data(repeating: 10, count: 32)), + alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], + managedRelayURLs: [] + ) + ) + _ = try await supervisor.activate() + let clock = EndpointServerManualClock() + let recorder = EndpointServerRecorder() + let server = CmxIrohEndpointServer( + supervisor: supervisor, + maximumPendingAdmissions: 1, + admissionTimeout: 15, + clock: clock + ) { connection, generation, _ in + await recorder.record( + identity: await connection.remoteIdentity(), + generation: generation + ) + await connection.close(errorCode: 0, reason: "test_complete") + } + + await server.start() + await endpoint.enqueueStalledHandshake() + await clock.waitUntilSleeping() + await clock.fire() + // The admission deadline fired against a handshake that never + // resolves on cancellation. The dialer is refused fast... + await endpoint.waitForAbandonCount(1) + + // ...but the slot must still be occupied: the next attempt has to be + // abandoned by the accept loop, not admitted alongside the live + // stalled handshake. Deterministic either way: the fix abandons the + // attempt ("admission_abandoned"), the defect admits it and the + // handler closes it "test_complete". + let overCapacity = TestIrohConnection( + remoteIdentity: healthyIdentity, + bidirectionalStreams: [] + ) + var overCapacityCloses = await overCapacity.closeEvents().makeAsyncIterator() + await endpoint.enqueue(overCapacity) + let close = try #require(await overCapacityCloses.next()) + #expect(close.reason == "admission_abandoned") + + // The driver finally bounds the stalled attempt. Its resolution, not + // the earlier deadline, releases the slot. + await endpoint.releaseStalledHandshakes() + let admittedAfterResolution = TestIrohConnection( + remoteIdentity: healthyIdentity, + bidirectionalStreams: [] + ) + await endpoint.enqueue(admittedAfterResolution) + #expect(await recorder.next().identity == healthyIdentity) + + await server.stop() + await supervisor.deactivate() + } +} + +/// An endpoint whose accept queue can contain a connection attempt that stops +/// making handshake progress, exactly like the production iroh accept path +/// when the dialing peer dies after its Initial packet. +private actor StalledHandshakeIrohEndpoint: CmxIrohEndpoint { + private enum AcceptEvent: Sendable { + case stalledHandshake + case connection(any CmxIrohConnection) + } + + private let peerIdentity: CmxIrohPeerIdentity + private var acceptEvents: [AcceptEvent] = [] + private var acceptWaiters: [UUID: CheckedContinuation] = [:] + private var stallWaiters: [CheckedContinuation] = [] + private var abandonCount = 0 + private var abandonWaiters: [(minimum: Int, continuation: CheckedContinuation)] = [] + private let health: AsyncStream + private let healthContinuation: AsyncStream.Continuation + + init(identity: CmxIrohPeerIdentity) { + peerIdentity = identity + let stream = AsyncStream.makeStream() + health = stream.stream + healthContinuation = stream.continuation + } + + func identity() -> CmxIrohPeerIdentity { peerIdentity } + + func address() -> CmxIrohEndpointAddress { + CmxIrohEndpointAddress(identity: peerIdentity, pathHints: []) + } + + func connect( + to _: CmxIrohEndpointAddress, + alpn _: Data + ) async throws -> any CmxIrohConnection { + throw TestIrohTransportError.unsupported + } + + func accept() async throws -> (any CmxIrohIncomingConnection)? { + let event: AcceptEvent + if !acceptEvents.isEmpty { + event = acceptEvents.removeFirst() + } else { + let id = UUID() + event = await withCheckedContinuation { acceptWaiters[id] = $0 } + } + switch event { + case .stalledHandshake: + // The dialing peer sent its Initial packet and then died. The + // server-side handshake never completes until release. + return StalledIncomingConnection(endpoint: self) + case let .connection(connection): + return CmxIrohEstablishedIncomingConnection(connection) + } + } + + func awaitStallRelease() async { + await withCheckedContinuation { stallWaiters.append($0) } + } + + func recordAbandon() { + abandonCount += 1 + let ready = abandonWaiters.filter { abandonCount >= $0.minimum } + abandonWaiters.removeAll { abandonCount >= $0.minimum } + for waiter in ready { waiter.continuation.resume() } + } + + func waitForAbandonCount(_ minimum: Int) async { + guard abandonCount < minimum else { return } + await withCheckedContinuation { + abandonWaiters.append((minimum, $0)) + } + } + + func healthEvents() -> AsyncStream { health } + func isHealthy() -> Bool { true } + + func close() { + releaseStalledHandshakes() + healthContinuation.finish() + } + + func enqueue(_ connection: any CmxIrohConnection) { + deliver(.connection(connection)) + } + + func enqueueStalledHandshake() { + deliver(.stalledHandshake) + } + + func releaseStalledHandshakes() { + let waiters = stallWaiters + stallWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + private func deliver(_ event: AcceptEvent) { + if let id = acceptWaiters.keys.first, + let continuation = acceptWaiters.removeValue(forKey: id) { + continuation.resume(returning: event) + } else { + acceptEvents.append(event) + } + } +} + +/// An incoming attempt whose server-side handshake makes no progress until the +/// endpoint releases it, then fails like an aborted handshake. +private struct StalledIncomingConnection: CmxIrohIncomingConnection { + let endpoint: StalledHandshakeIrohEndpoint + + func establish() async throws -> any CmxIrohConnection { + await endpoint.awaitStallRelease() + throw TestIrohTransportError.unsupported + } + + func abandon() async { + // Refusing a consumed attempt cannot stop the in-flight handshake, + // exactly like `Incoming.refuse()` after `accept()`. + await endpoint.recordAbandon() + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift index 4074fe48ea4e..455156c75c01 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift @@ -5,7 +5,7 @@ import Testing extension CmxIrohEndpointServerTests { @Test - func fullServerRejectsReconnectCandidateWithoutDisruptingActiveConnection() async throws { + func fullServerAdmitsOwnIdentityReplacementButRejectsOtherIdentities() async throws { let localIdentity = try CmxIrohPeerIdentity( endpointID: String(repeating: "8", count: 64) ) @@ -21,8 +21,7 @@ extension CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -53,33 +52,28 @@ extension CmxIrohEndpointServerTests { remoteIdentity: newIdentity, bidirectionalStreams: [] ) - var replacementCloses = await replacement.closeEvents().makeAsyncIterator() + var activeCloses = await active.closeEvents().makeAsyncIterator() var newcomerCloses = await newcomer.closeEvents().makeAsyncIterator() await server.start() await endpoint.enqueue(active) #expect(await started.next().identity == activeIdentity) + // At full capacity a reconnect from the SAME authenticated identity + // replaces its own never-usable predecessor instead of being refused: + // capacity held by a dead connection must not refuse its owner. await endpoint.enqueue(replacement) - for _ in 0 ..< 100 { - let startedCount = await started.recordedCount() - let replacementCloseCount = await replacement.observedCloseCallCount() - guard startedCount == 1, replacementCloseCount == 0 else { break } - await Task.yield() - } - #expect(await started.recordedCount() == 1) - let replacementCloseCount = await replacement.observedCloseCallCount() - #expect(replacementCloseCount == 1) - if replacementCloseCount == 1 { - let replacementClose = try #require(await replacementCloses.next()) - #expect(replacementClose.reason == "connection_capacity") - } - #expect(await active.observedCloseCallCount() == 0) + #expect(await started.next().identity == activeIdentity) + let activeClose = try #require(await activeCloses.next()) + #expect(activeClose.reason == "superseded_unready_connection") + #expect(await replacement.observedCloseCallCount() == 0) + // A DIFFERENT identity can never preempt an occupied slot. await endpoint.enqueue(newcomer) await newcomer.waitUntilClosed() let newcomerClose = try #require(await newcomerCloses.next()) #expect(newcomerClose.reason == "connection_capacity") + #expect(await replacement.observedCloseCallCount() == 0) await connectionLifetime.releaseAll() await server.stop() @@ -100,8 +94,7 @@ extension CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 3, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -161,8 +154,7 @@ extension CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 5, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -245,8 +237,7 @@ extension CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift index e1ef5a1a260f..42a7e2e5f4e8 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift @@ -19,8 +19,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 1, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) let snapshot = try await supervisor.activate() @@ -65,8 +64,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 4, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) let snapshot = try await supervisor.activate() @@ -112,8 +110,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -152,8 +149,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 2, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -214,8 +210,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 8, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -278,8 +273,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -333,8 +327,7 @@ struct CmxIrohEndpointServerTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) ) _ = try await supervisor.activate() @@ -503,7 +496,7 @@ actor TestAcceptingIrohEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { try Task.checkCancellation() if !acceptEvents.isEmpty { return try Self.resolve(acceptEvents.removeFirst()) @@ -519,7 +512,6 @@ actor TestAcceptingIrohEndpoint: CmxIrohEndpoint { return try Self.resolve(event) } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { health } func isHealthy() -> Bool { true } @@ -553,9 +545,10 @@ actor TestAcceptingIrohEndpoint: CmxIrohEndpoint { nonisolated private static func resolve( _ event: AcceptEvent - ) throws -> (any CmxIrohConnection)? { + ) throws -> (any CmxIrohIncomingConnection)? { switch event { - case let .connection(connection): connection + case let .connection(connection): + CmxIrohEstablishedIncomingConnection(connection) case .failure: throw TestIrohTransportError.unsupported case .closed: nil } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift index 6d20b5eaf69e..59340c8185fe 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift @@ -177,20 +177,16 @@ struct CmxIrohEndpointSupervisorTests { configuration: initialConfiguration ) _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) await #expect(throws: TestIrohTransportError.relayUpdateFailed) { - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) } await supervisor.deactivate() _ = try await supervisor.activate() let configurations = await factory.observedConfigurations() #expect(configurations.count == 2) - #expect(configurations[1].relays == initialConfiguration.relays) + #expect(configurations[1].relayProfile == initialConfiguration.relayProfile) } @Test @@ -207,12 +203,8 @@ struct CmxIrohEndpointSupervisorTests { configuration: initial ) _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) await supervisor.deactivate() _ = try await supervisor.activate() @@ -247,12 +239,8 @@ struct CmxIrohEndpointSupervisorTests { } } _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) let emittedChange = await changes.waitForRefresh(timeout: .seconds(1)) #expect( @@ -263,7 +251,7 @@ struct CmxIrohEndpointSupervisorTests { await supervisor.deactivate() } - @Test("relay credential rotation does not republish an unchanged address") + @Test("relay profile replacement does not republish an unchanged address") func unchangedRelayAddressDoesNotPublishNetworkChange() async throws { let endpoint = TestIrohEndpoint(identity: identity) let supervisor = CmxIrohEndpointSupervisor( @@ -280,17 +268,13 @@ struct CmxIrohEndpointSupervisorTests { } } _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) let emittedChange = await changes.waitForRefresh(timeout: .milliseconds(50)) #expect( !emittedChange, - "Rotating credentials without changing the published address must not refresh policy" + "Replacing relays without changing the published address must not refresh policy" ) observation.cancel() await supervisor.deactivate() @@ -342,12 +326,8 @@ struct CmxIrohEndpointSupervisorTests { ) _ = try await supervisor.activate() var updateEvents = await firstEndpoint.updateEvents().makeAsyncIterator() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) let refresh = Task { - try await supervisor.replaceRelays([replacement]) + try await supervisor.replaceRelayProfile(try replacementProfile()) } _ = await updateEvents.next() @@ -362,7 +342,7 @@ struct CmxIrohEndpointSupervisorTests { let configurations = await factory.observedConfigurations() #expect(configurations.count == 3) - #expect(configurations[2].relays == initialConfiguration.relays) + #expect(configurations[2].relayProfile == initialConfiguration.relayProfile) } @Test("an already-online generation replays relay readiness") @@ -497,33 +477,20 @@ struct CmxIrohEndpointSupervisorTests { private func endpointConfiguration( bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral ) throws -> CmxIrohEndpointConfiguration { - let relay = try relayConfiguration( - url: "https://use1-1.relay.lawrence.cmux.iroh.link/", - token: "aaaa" - ) - return try CmxIrohEndpointConfiguration( + try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], bindPolicy: bindPolicy, managedRelayURLs: [ - relay.url, + "https://use1-1.relay.lawrence.cmux.iroh.link/", "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ], - relays: [relay] + ] ) } - private func relayConfiguration( - url: String, - token: String - ) throws -> CmxIrohRelayConfiguration { - let now = Date(timeIntervalSince1970: 1_000) - return try CmxIrohRelayConfiguration( - url: url, - token: token, - expiresAt: now.addingTimeInterval(24 * 60 * 60), - refreshAfter: now.addingTimeInterval(12 * 60 * 60), - now: now + private func replacementProfile() throws -> CmxIrohEndpointRelayProfile { + try CmxIrohEndpointRelayProfile( + managedRelayURLs: ["https://usw1-1.relay.lawrence.cmux.iroh.link/"] ) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift index 5ec428dcee2f..c550f10b0446 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift @@ -16,8 +16,8 @@ extension CmxIrohHostRuntimeTests { func nonTransientRefreshRejectionFailsClosedThenRestarts() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let firstEndpoint = TestIrohEndpoint(identity: fixture.endpointID) - let restartEndpoint = TestIrohEndpoint(identity: fixture.endpointID) + let firstEndpoint = try fixture.relayReadyEndpoint() + let restartEndpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift index df5fd369cfa4..e041b4baf2f6 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift @@ -46,10 +46,15 @@ extension CmxIrohHostRuntimeTests { } ) try await runtime.start() + // Native iroh reports the home relay online once the configured relay + // connects; the address then carries the relay URL and the readiness + // gate publishes only after this. + await endpoint.setPathHints([relayHint]) + await endpoint.emit(.online) let republished = await broker.waitForRegistrationCount( 2, - timeout: .seconds(1) + timeout: .seconds(5) ) #expect( republished, @@ -75,14 +80,16 @@ extension CmxIrohHostRuntimeTests { #expect(initialDirectPorts == expectedDirectPorts) #expect(refreshedDirectPorts == expectedDirectPorts) + // The pre-relay state is never published; exactly one publication + // carries the newly usable relay address. + await runtime.waitForInitialPublicationForTesting() let published = await publications.values() - #expect(published.count == 2) - #expect(published[0].registration.pathHints.isEmpty) - #expect(published[0].discovered.pathHints.isEmpty) - #expect(published[1].registration.pathHints == [relayHint]) - #expect(published[1].discovered.pathHints == [relayHint]) - #expect(published[1].registration.endpointID == fixture.endpointID) - #expect(published[1].registration.bindingID == fixture.binding.bindingID) + let publication = try #require(published.first) + #expect(published.count == 1) + #expect(publication.registration.pathHints == [relayHint]) + #expect(publication.discovered.pathHints == [relayHint]) + #expect(publication.registration.endpointID == fixture.endpointID) + #expect(publication.registration.bindingID == fixture.binding.bindingID) let snapshot = await runtime.snapshot() #expect(snapshot.state == .active) @@ -92,38 +99,10 @@ extension CmxIrohHostRuntimeTests { await runtime.stop() } - @Test - func validatedBindingPublishesBeforeRelayCredentialInstallationCompletes() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeSuspensionGate() - let bindings = HostRuntimeBindingRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - relayIssueHook: { await gate.suspend() } - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleBinding: { _, _, _ in await bindings.record() } - ) - let start = Task { try await runtime.start() } - await gate.waitUntilSuspended() - - #expect(await bindings.count() == 1) - - await gate.resume() - try await start.value - await runtime.stop() - } - @Test func validatedBindingPublishesBeforeLANAdvertisementCompletes() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let gate = HostRuntimeSuspensionGate() let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift index cd49a7c3705f..f6e131d1ba2c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift @@ -30,7 +30,7 @@ extension CmxIrohHostRuntimeTests { ) ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery @@ -91,7 +91,7 @@ extension CmxIrohHostRuntimeTests { func unchangedReachabilityRenewsRegistrationBeforeHintExpiry() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery @@ -125,7 +125,7 @@ extension CmxIrohHostRuntimeTests { func registrationRenewalHonorsBrokerRetryAfterFloor() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, @@ -160,7 +160,7 @@ extension CmxIrohHostRuntimeTests { func registrationRenewalBacksOffConsecutiveFailures() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, @@ -201,7 +201,7 @@ extension CmxIrohHostRuntimeTests { func successfulRegistrationRenewalResetsBackoff() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, @@ -284,7 +284,13 @@ extension CmxIrohHostRuntimeTests { #expect(configurations.count == 1) #expect(configurations.first?.secretKey == fixture.identity.secretKey) #expect(configurations.first?.bindPolicy == .ephemeral) - #expect(configurations.first?.managedRelayURLs == fixture.managedRelays) + // A fresh host binds relay-less and installs the managed relays + // exactly once, after its registration is acknowledged, so the first + // relay dial cannot race the relay's admission hook. + #expect(configurations.first?.managedRelayURLs == []) + let relayUpdates = await endpoint.observedRelayProfileUpdates() + #expect(relayUpdates.count == 1) + #expect(relayUpdates.first?.allowedRelayURLs == fixture.managedRelays) let lan = try #require(await runtime.lanAdvertisementContext()) #expect(lan.binding == CmxIrohBrokerBindingMetadata(binding: fixture.binding)) #expect(lan.rendezvous == fixture.discovery.lanRendezvous) @@ -403,7 +409,7 @@ extension CmxIrohHostRuntimeTests { @Test func failedSignOutPersistenceClosesHostAndQuarantinesLocalState() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let store = TestControllableSecureCredentialStore() let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store) let deactivations = HostRuntimeDeactivationRecorder() @@ -447,7 +453,7 @@ extension CmxIrohHostRuntimeTests { @Test func successfulSignOutClearsRegistrationPublicationState() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let store = TestControllableSecureCredentialStore() let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -474,7 +480,7 @@ extension CmxIrohHostRuntimeTests { @Test func requiredBindPolicyIsForwardedToTheEndpointGeneration() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, @@ -504,17 +510,20 @@ extension CmxIrohHostRuntimeTests { } @Test - func connectivityFailureUsesVerifiedCacheOnlyAfterOnlineAttempt() async throws { + func cacheFirstActivationBecomesActiveDespiteBrokerConnectivityFailure() async throws { let fixture = try HostRuntimeFixture() let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now let cachedPolicy = try cachedFixture.policy() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, - registrationError: .connectivity + registrationError: .connectivity, + subsequentRegistrationErrors: [ + .connectivity, .connectivity, .connectivity, + ] ) let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( @@ -527,11 +536,17 @@ extension CmxIrohHostRuntimeTests { handleBinding: { _, _, _ in await bindings.record() } ) + // The verified cache activates admission immediately; the failed + // background reconcile keeps cached authority active without a fresh + // publication until a live round succeeds. try await runtime.start() - #expect(await broker.observedRegistrationCount() == 1) + #expect(await runtime.snapshot().state == .active) #expect(await runtime.snapshot().bindingID == cachedPolicy.binding.bindingID) #expect(await runtime.lanAdvertisementContext()?.rendezvous == cachedPolicy.lanRendezvous) + await runtime.waitForInitialPublicationForTesting() + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + #expect(await runtime.snapshot().state == .active) #expect(await bindings.count() == 0) await runtime.stop() } @@ -564,7 +579,7 @@ extension CmxIrohHostRuntimeTests { configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), pendingRevocations: fixture.pendingRevocations(), now: { now }, - registrationClock: ImmediateHostActivationClock(), + registrationClock: HostRegistrationRenewalClock(now: now), handleTransport: { session, _ in await session.close() }, handleRoute: { binding, pathHints in await routes.record(binding: binding, pathHints: pathHints) @@ -573,7 +588,6 @@ extension CmxIrohHostRuntimeTests { try await runtime.start() - #expect(await broker.observedRegistrationCount() == 1) #expect(await runtime.snapshot().state == .active) #expect(await runtime.snapshot().bindingID == cachedPolicy.binding.bindingID) #expect(await routes.values() == [ @@ -588,8 +602,7 @@ extension CmxIrohHostRuntimeTests { let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now let currentPort: UInt16 = 55_123 - let endpoint = TestIrohEndpoint( - identity: fixture.endpointID, + let endpoint = try fixture.relayReadyEndpoint( directAddresses: ["0.0.0.0:\(currentPort)"] ) let factory = TestIrohEndpointFactory(endpoints: [endpoint]) @@ -682,7 +695,7 @@ extension CmxIrohHostRuntimeTests { @Test func endpointNetworkChangeRequestsImmediateLANRefresh() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let recorder = HostRuntimeLANRefreshRecorder() let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -707,7 +720,7 @@ extension CmxIrohHostRuntimeTests { @Test func repeatedUnchangedNetworkEventsDoNotContactBroker() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery @@ -742,7 +755,7 @@ extension CmxIrohHostRuntimeTests { @Test func changedDirectPortPublishesImmediately() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery @@ -768,7 +781,7 @@ extension CmxIrohHostRuntimeTests { @Test func endpointOnlineRequestsImmediateReachabilityRefresh() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let recorder = HostRuntimeLANRefreshRecorder() let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [endpoint]), @@ -809,7 +822,7 @@ extension CmxIrohHostRuntimeTests { _ failure: CmxIrohTrustBrokerClientError ) async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift index 045738364aeb..0b48a7c6302c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift @@ -32,7 +32,7 @@ extension CmxIrohHostRuntimeTests { ) let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), + try fixture.relayReadyEndpoint(), ]), broker: broker, configuration: fixture.configuration, @@ -118,7 +118,7 @@ extension CmxIrohHostRuntimeTests { ) let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), + try fixture.relayReadyEndpoint(), ]), broker: broker, configuration: fixture.configuration, @@ -179,7 +179,7 @@ extension CmxIrohHostRuntimeTests { lanGeneration: 1, revision: 1 ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: revisionTwo, @@ -237,7 +237,7 @@ extension CmxIrohHostRuntimeTests { @Test func networkChangeDuringActiveRefreshDoesNotRequestAnotherRegistration() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let gate = HostRuntimeRegistrationGate() let refreshes = HostRuntimeLANRefreshRecorder() let broker = TestIrohHostBroker( @@ -278,8 +278,7 @@ extension CmxIrohHostRuntimeTests { relays: Array(fixture.managedRelays), lanGeneration: 2 ) - let endpoint = TestIrohEndpoint( - identity: fixture.endpointID, + let endpoint = try fixture.relayReadyEndpoint( directAddresses: ["192.168.1.10:50906"] ) let policies = HostRuntimeLANPolicyRecorder() @@ -318,19 +317,20 @@ extension CmxIrohHostRuntimeTests { .rejected(statusCode: 400, code: "invalid_request"), .invalidResponse, ]) - func terminalBrokerFailureNeverUsesCachedPolicy( + func terminalBrokerFailureFailsClosedAfterCacheFirstActivation( _ failure: CmxIrohTrustBrokerClientError ) async throws { let fixture = try HostRuntimeFixture() let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, discovery: fixture.discovery, registrationError: failure ) + let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( factory: factory, broker: broker, @@ -339,18 +339,19 @@ extension CmxIrohHostRuntimeTests { ), pendingRevocations: fixture.pendingRevocations(), now: { now }, - handleTransport: { session, _ in await session.close() } + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } ) - do { - try await runtime.start() - Issue.record("Expected terminal broker failure") - } catch let error as CmxIrohTrustBrokerClientError { - #expect(error == failure) - } + // Cache-first activation succeeds locally, but the background live + // reconcile discovers the terminal rejection and fails closed: a Mac + // the broker refuses must not keep serving on cached authority. + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() - #expect(await endpoint.observedCloseCallCount() == 1) #expect(await runtime.snapshot().state == .failed) + #expect(await endpoint.waitForCloseCallCount(1)) + #expect(await bindings.count() == 0) } @Test @@ -367,7 +368,7 @@ extension CmxIrohHostRuntimeTests { ) let cachedFixture = try fixture.cachedPolicyFixture(binding: cachedMetadata) let now = cachedFixture.now - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, @@ -386,8 +387,13 @@ extension CmxIrohHostRuntimeTests { handleBinding: { _, _, _ in await bindings.record() } ) + // Cache-first activation starts on the persisted binding; the live + // reconcile discovers it was replaced server-side and adopts the + // authenticated binding in place, publishing it exactly once. try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + #expect(await runtime.snapshot().state == .active) #expect(await runtime.snapshot().bindingID == fixture.binding.bindingID) #expect(await bindings.count() == 1) await runtime.stop() @@ -459,7 +465,7 @@ extension CmxIrohHostRuntimeTests { } @Test - func confirmedOnlineBindingChangePreventsDiscoveryConnectivityFallback() async throws { + func halfConfirmedBindingChangeIsNeverAdoptedNorPublished() async throws { let fixture = try HostRuntimeFixture() let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now @@ -467,13 +473,14 @@ extension CmxIrohHostRuntimeTests { endpointID: fixture.endpointID.endpointID, bindingID: "123e4567-e89b-42d3-a456-426614174099" ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: changedBinding, discovery: fixture.discovery, discoveryError: .connectivity ) + let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( factory: factory, broker: broker, @@ -482,18 +489,24 @@ extension CmxIrohHostRuntimeTests { ), pendingRevocations: fixture.pendingRevocations(), now: { now }, - handleTransport: { session, _ in await session.close() } + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } ) - await #expect(throws: CmxIrohHostRuntimeError.invalidLocalBinding) { - try await runtime.start() - } + // The reconcile registers a replaced binding but its discovery round + // fails on connectivity. The half-confirmed binding is confirmed + // proof that cached authority is stale, so the runtime fails closed + // without adopting or publishing the incomplete policy. + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() - #expect(await endpoint.observedCloseCallCount() == 1) + #expect(await runtime.snapshot().state == .failed) + #expect(await bindings.count() == 0) + #expect(await endpoint.waitForCloseCallCount(1)) } @Test - func routeContractMismatchNeverUsesCachedPolicy() async throws { + func routeContractMismatchFailsClosedAfterCacheFirstActivation() async throws { let fixture = try HostRuntimeFixture() let cachedFixture = try fixture.cachedPolicyFixture() let now = cachedFixture.now @@ -502,7 +515,7 @@ extension CmxIrohHostRuntimeTests { relays: Array(fixture.managedRelays), routeContractVersion: 2 ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let broker = TestIrohHostBroker( registrationBinding: fixture.binding, @@ -519,17 +532,17 @@ extension CmxIrohHostRuntimeTests { handleTransport: { session, _ in await session.close() } ) - await #expect(throws: CmxIrohHostRuntimeError.routeContractMismatch) { - try await runtime.start() - } + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() - #expect(await endpoint.observedCloseCallCount() == 1) + #expect(await runtime.snapshot().state == .failed) + #expect(await endpoint.waitForCloseCallCount(1)) } @Test func discoverySubstitutionFailsClosedAndClosesEndpoint() async throws { let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let endpoint = try fixture.relayReadyEndpoint() let factory = TestIrohEndpointFactory(endpoints: [endpoint]) let substituted = try HostRuntimeFixture.discovery( binding: fixture.binding, @@ -553,12 +566,11 @@ extension CmxIrohHostRuntimeTests { handleTransport: { session, _ in await session.close() } ) - await #expect(throws: CmxIrohHostRuntimeError.invalidLocalBinding) { - try await runtime.start() - } + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() - #expect(await endpoint.observedCloseCallCount() == 1) #expect(await runtime.snapshot().state == .failed) + #expect(await endpoint.waitForCloseCallCount(1)) } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRelayRecoveryTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRelayRecoveryTests.swift new file mode 100644 index 000000000000..4e41aa4b713b --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRelayRecoveryTests.swift @@ -0,0 +1,150 @@ +import CMUXMobileCore +import Foundation +import Testing + +@testable import CmuxIrohTransport + +/// Regression coverage for cmux#10873: a Mac that activated during a relay +/// policy outage (expired policy cache, persistently failing refresh) runs +/// with zero managed relays and publishes a direct-only registration. When a +/// later policy refresh finally succeeds, installing the recovered policy +/// must both attach the managed relay on the live endpoint AND republish the +/// registration, without an app restart, so remote clients can reach the +/// host again. +struct CmxIrohHostRuntimeRelayRecoveryTests { + @Test("relay policy recovery after outage attaches and republishes") + func recoveryAfterOutageAttachesAndRepublishes() async throws { + let fixture = try HostRuntimeFixture() + let recoveredRelayURL = HostRuntimeFixture.relayURLs[2] + // The endpoint has no relay hints: exactly the outage shape, where the + // host bound with `.unavailableManagedSelection` (zero relays). + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration( + endpointRelayProfile: .unavailableManagedSelection, + managedRelayURLs: [] + ), + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + // Outage steady state: active, published direct-only, no relays. + #expect(await runtime.snapshot().state == .active) + #expect(await bindings.count() == 1) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + + // A later policy refresh succeeds and the recovered policy is + // installed on the running host. + try await runtime.replaceRelayPolicy( + Self.recoveredPolicy(selectedRelayURL: recoveredRelayURL) + ) + + // Attach: the live endpoint received the recovered relay profile. + #expect( + await endpoint.observedRelayProfileUpdates().last?.allowedRelayURLs + == [recoveredRelayURL] + ) + // Publish: the host re-registers and republishes without a restart, + // so the broker can serve the recovered relay route to clients. + #expect(await bindings.waitForCount(2, timeout: .seconds(5))) + await runtime.waitForRegistrationRefreshRoundsForTesting() + #expect(await routes.values().count >= 2) + + await runtime.stop() + } + + /// A repeated install of an unchanged relay profile (every periodic + /// policy refresh success re-applies the effective policy) must NOT force + /// a broker registration round each time. + @Test("unchanged relay profile reinstall does not republish") + func unchangedProfileReinstallDoesNotRepublish() async throws { + let fixture = try HostRuntimeFixture() + let recoveredRelayURL = HostRuntimeFixture.relayURLs[2] + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let bindings = HostRuntimeBindingRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration( + endpointRelayProfile: .unavailableManagedSelection, + managedRelayURLs: [] + ), + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } + ) + try await runtime.start() + #expect(await bindings.count() == 1) + + let recovered = Self.recoveredPolicy(selectedRelayURL: recoveredRelayURL) + try await runtime.replaceRelayPolicy(recovered) + #expect(await bindings.waitForCount(2, timeout: .seconds(5))) + await runtime.waitForRegistrationRefreshRoundsForTesting() + let publishedAfterRecovery = await bindings.count() + + // Same policy again: no relay change, no forced round. + try await runtime.replaceRelayPolicy(recovered) + await runtime.waitForRegistrationRefreshRoundsForTesting() + #expect(await bindings.count() == publishedAfterRecovery) + + await runtime.stop() + } + + /// An effective policy whose managed catalog carries the fixture fleet + /// and whose endpoint profile selects `selectedRelayURL`. + private static func recoveredPolicy( + selectedRelayURL: String + ) -> CmxIrohEffectiveRelayPolicy { + let descriptors = HostRuntimeFixture.relayURLs.enumerated().map { + index, url in + CmxIrohManagedRelayDescriptor( + id: "cmux-relay-\(index)", + provider: "cmux", + region: "region-\(index)", + url: url + ) + } + let policy = CmxIrohManagedRelayPolicy( + version: 1, + policyID: "123e4567-e89b-42d3-a456-426614174777", + sequence: 9, + issuedAt: 1_800_000_000, + notBefore: 1_800_000_000, + expiresAt: 1_800_003_600, + audience: "cmux-iroh-relay-policy", + relayProtocol: "iroh-relay-v1", + relays: descriptors + ) + let profile = try! CmxIrohEndpointRelayProfile( + managedRelayURLs: [selectedRelayURL] + ) + return CmxIrohEffectiveRelayPolicy( + endpointRelayProfile: profile, + managedSnapshot: nil, + managedPolicy: policy, + requestedConfiguration: .automatic, + effectivePreference: .automatic, + source: .managed, + usedCachedPolicy: false, + preferenceRevision: 3 + ) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift index d13387d7a1fc..6c3e55c29a52 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeRequestedRefreshTests.swift @@ -27,7 +27,7 @@ extension CmxIrohHostRuntimeTests { let bindings = HostRuntimeBindingRecorder() let runtime = CmxIrohHostRuntime( factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), + try fixture.relayReadyEndpoint(), ]), broker: broker, configuration: fixture.configuration, @@ -218,17 +218,6 @@ private actor TestRevisionedHostBroker: throw TestIrohTransportError.unsupported } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) -> CmxIrohRelayTokenResponse { - CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-07-10T12:00:00.000Z", - refreshAfter: "2027-07-10T11:00:00.000Z", - relayFleet: HostRuntimeFixture.relayURLs - ) - } func revoke(bindingID _: String) {} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift new file mode 100644 index 000000000000..98ceefc1e1c4 --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeStartupPublicationTests.swift @@ -0,0 +1,564 @@ +import CMUXMobileCore +import Foundation +import Testing + +@testable import CmuxIrohTransport + +extension CmxIrohHostRuntime { + /// Awaits the startup ready gate and every refresh round it scheduled, so + /// tests observe the settled post-reconcile state deterministically. + func waitForInitialPublicationForTesting() async { + await initialPublicationTask?.value + while let task = registrationRefreshTask { + await task.value + } + } + + /// Awaits only the ready gate task, without draining refresh rounds, so a + /// test can observe the gate handing its deferred publication to an + /// in-flight round that is deliberately parked at the broker. + func waitForInitialPublicationGateForTesting() async { + await initialPublicationTask?.value + } + + /// Awaits every scheduled refresh round, including coalesced replays, + /// without touching the ready gate. + func waitForRegistrationRefreshRoundsForTesting() async { + while let task = registrationRefreshTask { + await task.value + } + } +} + +extension TestIrohEndpoint { + /// A network-change refresh can own the terminal round and still be + /// finishing its teardown when the publication pipeline await returns. + /// Bounded wait for the endpoint close that teardown must perform. + func waitForCloseCallCount(_ minimum: Int) async -> Bool { + for _ in 0 ..< 50_000 { + if observedCloseCallCount() >= minimum { return true } + await Task.yield() + } + return observedCloseCallCount() >= minimum + } +} + +extension CmxIrohHostRuntimeTests { + /// Regression for the advertise-before-ready warm-up race + /// (https://github.com/manaflow-ai/cmux/issues/9724): a Mac must not + /// publish its binding or route hints while its home relay is still + /// warming up, because clients immediately burn doomed dials against an + /// endpoint that cannot yet accept them. The binding and route may only + /// be published once the relay is usable, with the post-relay hints, and + /// exactly once. + @Test("binding publication waits for a usable home relay") + func bindingPublicationWaitsForUsableHomeRelay() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + let readyBinding = try HostRuntimeFixture.binding( + endpointID: fixture.endpointID.endpointID, + bindingID: fixture.binding.bindingID, + publicHintObservedAt: now, + publicHintExpiresAt: now.addingTimeInterval(60 * 60) + ) + let relayHint = try #require(readyBinding.pathHints.first) + let readyDiscovery = try HostRuntimeFixture.discovery( + binding: readyBinding, + relays: HostRuntimeFixture.relayURLs + ) + // The endpoint gains its usable relay hint only after the relay + // credential coordinator installs the first credential, exactly like + // a cold production launch. + let endpoint = TestIrohEndpoint( + identity: fixture.endpointID, + pathHintsAfterRelayReplacement: [relayHint] + ) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery, + subsequentRegistrationBindings: [readyBinding], + subsequentDiscoveries: [readyDiscovery] + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + // No usable home relay exists yet: the Mac must not be + // discoverable-but-undialable. + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + #expect(await runtime.snapshot().state == .active) + + // Native iroh reports the home relay online. Publication must follow, + // exactly once, with the post-relay hints. + await endpoint.emit(.online) + #expect(await bindings.waitForCount(1, timeout: .seconds(5))) + let republished = await routes.values() + #expect(republished.map(\.binding.bindingID) == [fixture.binding.bindingID]) + #expect(republished.map(\.pathHints) == [[relayHint]]) + #expect(!(await bindings.waitForCount(2, timeout: .milliseconds(300)))) + + await runtime.stop() + } + + /// Cache-first activation: a persisted verified policy for the same + /// binding makes the Mac dialable immediately. The live broker round is + /// only a background reconcile, so start() completes while the broker has + /// not yet answered at all. + @Test("cache-first start becomes ready without a live broker response") + func cacheFirstStartBecomesReadyWithoutALiveBrokerResponse() async throws { + let fixture = try HostRuntimeFixture() + let cachedFixture = try fixture.cachedPolicyFixture() + let now = cachedFixture.now + let cachedPolicy = try cachedFixture.policy() + let registrationGate = HostRuntimeRegistrationGate() + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery, + registrationHook: { + await registrationGate.waitOnce() + return true + } + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [ + try fixture.relayReadyEndpoint(), + ]), + broker: broker, + configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + #expect(await runtime.snapshot().state == .active) + #expect(await runtime.snapshot().bindingID == cachedPolicy.binding.bindingID) + #expect(await runtime.lanAdvertisementContext()?.rendezvous == cachedPolicy.lanRendezvous) + // The cached route identity is refreshed, never unpublished, but no + // fresh binding may be published while the live round is unanswered. + #expect(await routes.values() == [ + .init(binding: cachedPolicy.binding, pathHints: []), + ]) + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + #expect(await runtime.snapshot().state == .active) + #expect(await bindings.count() == 0) + + await registrationGate.open() + await runtime.waitForInitialPublicationForTesting() + + #expect(await bindings.count() == 1) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// The late live policy reconciles onto a cache-first activation: the + /// same binding is re-verified and the fresh broker route hints replace + /// the empty cached ones. + @Test("late live policy reconciles a cache-first activation") + func lateLivePolicyReconcilesCacheFirstActivation() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) + let discoveredHint = try #require(fixture.binding.pathHints.first) + let cachedFixture = try fixture.cachedPolicyFixture() + let cachedPolicy = try cachedFixture.policy() + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [ + try fixture.relayReadyEndpoint(), + ]), + broker: broker, + configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + + #expect(await broker.observedRegistrationCount() == 1) + #expect(await bindings.count() == 1) + #expect(await routes.values() == [ + .init(binding: cachedPolicy.binding, pathHints: []), + .init( + binding: CmxIrohBrokerBindingMetadata(binding: fixture.binding), + pathHints: [discoveredHint] + ), + ]) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A relay-readiness timeout must never publish. The gate keeps retrying + /// the readiness wait on the injected clock; once the relay becomes + /// usable, exactly one publication follows. + @Test("readiness timeouts keep the binding unpublished until the relay succeeds") + func readinessTimeoutsKeepBindingUnpublishedUntilRelaySucceeds() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let clock = HostRegistrationRenewalClock(now: now) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { clock.now() }, + registrationClock: clock, + registrationRetryJitter: { 0 }, + relayReadinessTimeout: .milliseconds(20), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + #expect(await bindings.count() == 0) + + // First readiness timeout: still unpublished, backoff armed. + await clock.waitUntilSleepCount(1) + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + clock.advance(to: try #require(clock.observedSleepDeadlines().last)) + + // Second readiness timeout: still unpublished. + await clock.waitUntilSleepCount(2) + #expect(await bindings.count() == 0) + + // The home relay comes up. Publication must follow, exactly once. + await endpoint.emit(.online) + + #expect(await bindings.waitForCount(1, timeout: .seconds(5))) + #expect(!(await bindings.waitForCount(2, timeout: .milliseconds(300)))) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A relay that never becomes usable must leave the endpoint permanently + /// unpublished: no handleBinding, no handleRoute, no extra broker rounds. + @Test("readiness that never succeeds never publishes") + func readinessThatNeverSucceedsNeverPublishes() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let clock = HostRegistrationRenewalClock(now: now) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { clock.now() }, + registrationClock: clock, + registrationRetryJitter: { 0 }, + relayReadinessTimeout: .milliseconds(20), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + for cycle in 1 ... 3 { + await clock.waitUntilSleepCount(cycle) + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + clock.advance(to: try #require(clock.observedSleepDeadlines().last)) + } + + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + #expect(await broker.observedRegistrationCount() == 1) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A requested refresh must not perform the deferred first publication + /// while the home relay is still unusable, even though its authenticated + /// broker round runs and applies admission policy. + @Test("a requested refresh while the relay is unready does not publish") + func requestedRefreshWhileRelayUnreadyDoesNotPublish() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + await runtime.requestRegistrationRefresh() + + #expect(await broker.observedRegistrationCount() == 2) + #expect(await bindings.count() == 0) + #expect(await routes.values().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A refresh round that observes the deferred first publication while the + /// relay is unusable must re-arm the ready gate. The gate consumes itself + /// by handing the publication to an in-flight round; when that round then + /// finds readiness lost (relay profile rotation un-latches it without a + /// health event) and the binding is too stale to arm a renewal deadline, + /// no owner remains: a relay that silently becomes usable again (a + /// reconnect iroh does not re-announce) would never publish the binding. + @Test("a not-ready refresh re-arms the ready gate for the deferred first publication") + func notReadyRefreshReArmsTheReadyGateForTheDeferredFirstPublication() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let fixture = try HostRuntimeFixture(now: now) + // Stale enough that neither binding freshness nor hint expiry can arm + // a registration renewal deadline. + let staleBinding = try HostRuntimeFixture.binding( + endpointID: fixture.endpointID.endpointID, + lastSeenAt: now.addingTimeInterval(-24 * 60 * 60) + ) + let staleDiscovery = try HostRuntimeFixture.discovery( + binding: staleBinding, + relays: HostRuntimeFixture.relayURLs + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let registrationGate = HostRuntimeRegistrationGate() + let broker = TestIrohHostBroker( + registrationBinding: staleBinding, + discovery: staleDiscovery, + subsequentRegistrationHook: { await registrationGate.waitOnce() } + ) + let clock = HostRegistrationRenewalClock(now: now) + let bindings = HostRuntimeBindingRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + now: { clock.now() }, + registrationClock: clock, + registrationRetryJitter: { 0 }, + relayReadinessTimeout: .milliseconds(20), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } + ) + + try await runtime.start() + #expect(await bindings.count() == 0) + // The activation ready gate times out its first readiness wait and + // parks in its backoff on the injected clock. + await clock.waitUntilSleepCount(1) + + // The home relay comes up. The network-change refresh starts and + // parks at the broker; the woken gate hands its deferred publication + // to that in-flight round and consumes itself. + await endpoint.emit(.online) + #expect(await broker.waitForRegistrationCount(2, timeout: .seconds(5))) + clock.advance(to: try #require(clock.observedSleepDeadlines().last)) + await runtime.waitForInitialPublicationGateForTesting() + + // A relay profile rotation un-latches readiness. The endpoint address + // is unchanged, so no network-change round is published: the parked + // round is the last owner of the deferred first publication. + try await runtime.replaceRelayProfile( + fixture.configuration.resolvedEndpointRelayProfile(debugOverride: nil) + ) + + // The parked round resumes and correctly refuses to publish while the + // relay is unusable. + await registrationGate.open() + await runtime.waitForRegistrationRefreshRoundsForTesting() + #expect(await bindings.count() == 0) + + // The relay becomes usable again with no health event. Only the + // re-armed ready gate can observe this; drive its readiness backoff + // on the injected clock until the publication lands. + await endpoint.setPathHints([try HostRuntimeFixture.usableRelayHint()]) + var advancedDeadlineCount = clock.observedSleepDeadlines().count + var published = false + for _ in 0 ..< 10 { + if await bindings.waitForCount(1, timeout: .milliseconds(500)) { + published = true + break + } + let deadlines = clock.observedSleepDeadlines() + if deadlines.count > advancedDeadlineCount, let last = deadlines.last { + advancedDeadlineCount = deadlines.count + clock.advance(to: last) + } + } + #expect(published) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A cache-first activation whose live reconcile adopts a server-side + /// replacement binding while the home relay is still unusable must move + /// the ready gate onto the adopted identity: the stale gate armed with + /// the superseded cached binding is drained so it can never activate the + /// replacement relay coordinator with the old binding, nothing publishes + /// before the relay is usable, and afterwards exactly the adopted + /// binding publishes, once. + @Test("adoption while the relay is unready re-arms the gate on the adopted binding") + func adoptionWhileRelayUnreadyReArmsGateOnAdoptedBinding() async throws { + let fixture = try HostRuntimeFixture() + let cachedFixture = try fixture.cachedPolicyFixture() + let cachedPolicy = try cachedFixture.policy() + let now = cachedFixture.now + let replacementBinding = try HostRuntimeFixture.binding( + endpointID: fixture.endpointID.endpointID, + bindingID: "123e4567-e89b-42d3-a456-426614174099" + ) + let replacementDiscovery = try HostRuntimeFixture.discovery( + binding: replacementBinding, + relays: HostRuntimeFixture.relayURLs + ) + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: replacementBinding, + discovery: replacementDiscovery + ) + let bindings = HostRuntimeBindingRecorder() + let routes = HostRuntimeRouteRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() }, + handleRoute: { binding, pathHints in + await routes.record(binding: binding, pathHints: pathHints) + } + ) + + try await runtime.start() + + // Cache-first start on the persisted binding: the cached route + // identity is refreshed, nothing publishes while the relay warms up. + #expect(await runtime.snapshot().state == .active) + #expect(await routes.values() == [ + .init(binding: cachedPolicy.binding, pathHints: []), + ]) + #expect(await bindings.count() == 0) + + // The live reconcile adopts the server-side replacement binding. The + // relay is still unusable, so the adopted binding must stay + // unpublished. + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + var adopted = false + for _ in 0 ..< 50_000 { + if await runtime.snapshot().bindingID == replacementBinding.bindingID { + adopted = true + break + } + await Task.yield() + } + #expect(adopted) + #expect(await bindings.count() == 0) + + // The home relay comes online for the adopted binding. Publication + // must follow, exactly once, with the adopted identity. + await endpoint.emit(.online) + #expect(await bindings.waitForCount(1, timeout: .seconds(5))) + #expect(!(await bindings.waitForCount(2, timeout: .milliseconds(300)))) + let published = await routes.values() + #expect(published.first?.binding.bindingID == cachedPolicy.binding.bindingID) + #expect(published.last?.binding.bindingID == replacementBinding.bindingID) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// Cached authority must be verified against the live broker even when + /// the home relay never becomes usable: a server-side rejection fails + /// the runtime closed instead of hiding behind the relay outage. + @Test("stale cached authority fails closed without relay readiness") + func staleCachedAuthorityFailsClosedWithoutRelayReadiness() async throws { + let fixture = try HostRuntimeFixture() + let cachedFixture = try fixture.cachedPolicyFixture() + let now = cachedFixture.now + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery, + registrationError: .missingAuthentication + ) + let bindings = HostRuntimeBindingRecorder() + let runtime = CmxIrohHostRuntime( + factory: TestIrohEndpointFactory(endpoints: [endpoint]), + broker: broker, + configuration: fixture.configuration( + cachedHostPolicy: try cachedFixture.policy() + ), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + relayReadinessTimeout: .milliseconds(50), + handleTransport: { session, _ in await session.close() }, + handleBinding: { _, _, _ in await bindings.record() } + ) + + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + #expect(await endpoint.waitForCloseCallCount(1)) + #expect(await runtime.snapshot().state == .failed) + #expect(await bindings.count() == 0) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift index 812e494a9801..39431794b752 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift @@ -97,6 +97,34 @@ struct HostRuntimeFixture { ) } + /// A public relay hint usable on the supervisor's wall clock for one hour. + /// An endpoint born with it reports a usable home relay immediately, so + /// activation publishes the binding inline instead of waiting on the ready + /// gate. Fixtures that pin `now` far in the future exclude it from + /// registration payloads automatically, keeping those payloads unchanged. + static func usableRelayHint() throws -> CmxIrohPathHint { + let observed = Date() + return try CmxIrohPathHint( + kind: .relayURL, + value: relayURLs[2], + source: .native, + privacyScope: .publicInternet, + observedAt: observed, + expiresAt: observed.addingTimeInterval(60 * 60) + ) + } + + /// An endpoint whose home relay is usable from birth. + func relayReadyEndpoint( + directAddresses: [String] = [] + ) throws -> TestIrohEndpoint { + TestIrohEndpoint( + identity: endpointID, + directAddresses: directAddresses, + pathHints: [try Self.usableRelayHint()] + ) + } + static let relayURLs = [ "https://aps1-1.relay.lawrence.cmux.iroh.link/", "https://euc1-1.relay.lawrence.cmux.iroh.link/", diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift index 1a25463a0ade..2c1f52e6fc1a 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift @@ -35,6 +35,93 @@ struct CmxIrohHostRuntimeTests { await runtime.stop() } + /// A fresh Mac host (no verified cached policy) must not dial a managed, + /// admission-gated relay before its broker registration is acknowledged: + /// the relay's allow hook denies an unregistered endpoint and negatively + /// caches the deny, costing the whole first activation. The endpoint + /// binds relay-less and the managed relays are installed only after + /// registration returns. + @Test + func freshHostWithholdsManagedRelaysUntilRegistrationIsAcknowledged() async throws { + let fixture = try HostRuntimeFixture() + let registrationGate = HostRuntimeRegistrationGate() + let endpoint = TestIrohEndpoint(identity: fixture.endpointID) + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery, + registrationHook: { + await registrationGate.waitOnce() + return true + } + ) + let runtime = CmxIrohHostRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration, + pendingRevocations: fixture.pendingRevocations(), + handleTransport: { session, _ in await session.close() } + ) + + let start = Task { try await runtime.start() } + #expect(await broker.waitForRegistrationCount(1, timeout: .seconds(5))) + // The endpoint is bound and its registration is held in flight: no + // managed relay may be active at bind or installed yet. + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect(boundConfigurations.first?.relayProfile.activeRelays.isEmpty == true) + #expect(boundConfigurations.first?.relayProfile.allowedRelayURLs.isEmpty == true) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + + await registrationGate.open() + try await start.value + + let updates = await endpoint.observedRelayProfileUpdates() + #expect(updates.count == 1) + #expect(updates.first?.allowedRelayURLs == fixture.managedRelays) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + + /// A verified cached policy proves the broker already acknowledged this + /// endpoint, so cache-first activation keeps the managed relays installed + /// at bind and performs no post-registration relay swap. This pins the + /// warm ~20ms start path of the cache-first design. + @Test + func cachedPolicyKeepsManagedRelaysInstalledAtBind() async throws { + let fixture = try HostRuntimeFixture() + let cachedFixture = try fixture.cachedPolicyFixture() + let now = cachedFixture.now + let cachedPolicy = try cachedFixture.policy() + let endpoint = try fixture.relayReadyEndpoint() + let factory = TestIrohEndpointFactory(endpoints: [endpoint]) + let broker = TestIrohHostBroker( + registrationBinding: fixture.binding, + discovery: fixture.discovery + ) + let runtime = CmxIrohHostRuntime( + factory: factory, + broker: broker, + configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), + pendingRevocations: fixture.pendingRevocations(), + now: { now }, + handleTransport: { session, _ in await session.close() } + ) + + try await runtime.start() + await runtime.waitForInitialPublicationForTesting() + + let boundConfigurations = await factory.observedConfigurations() + #expect(boundConfigurations.count == 1) + #expect( + boundConfigurations.first?.relayProfile.allowedRelayURLs + == fixture.managedRelays + ) + #expect(await endpoint.observedRelayProfileUpdates().isEmpty) + #expect(await runtime.snapshot().state == .active) + await runtime.stop() + } + @Test("direct-only startup does not wait for relay readiness") func directOnlyStartupSkipsRelayReadiness() async throws { let fixture = try HostRuntimeFixture() @@ -58,7 +145,6 @@ struct CmxIrohHostRuntimeTests { try await runtime.start() #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) await runtime.stop() } @@ -381,7 +467,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { private let revokeError: CmxIrohTrustBrokerClientError? private let registrationHook: (@Sendable () async -> Bool)? private let subsequentRegistrationHook: (@Sendable () async -> Void)? - private let relayIssueHook: (@Sendable () async -> Void)? private let embedDiscoveryStartingAtRegistrationCount: Int? private let embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? private let embeddedRegistrationDiscoveryIsComplete: Bool? @@ -391,7 +476,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { private var preflightOperations: [CmxIrohBrokerOperation] = [] private var registrationCount = 0 private var preparedRegistrations: [CmxIrohPreparedRegistration] = [] - private var relayIssueCount = 0 private var discoveryCount = 0 private var registrationHookResult: Bool? private var revokedBindingIDs: [String] = [] @@ -409,7 +493,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { revokeError: CmxIrohTrustBrokerClientError? = nil, registrationHook: (@Sendable () async -> Bool)? = nil, subsequentRegistrationHook: (@Sendable () async -> Void)? = nil, - relayIssueHook: (@Sendable () async -> Void)? = nil, embedDiscoveryInRegistration: Bool = false, embedDiscoveryStartingAtRegistrationCount: Int? = nil, embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? = nil, @@ -425,7 +508,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { self.revokeError = revokeError self.registrationHook = registrationHook self.subsequentRegistrationHook = subsequentRegistrationHook - self.relayIssueHook = relayIssueHook self.embedDiscoveryStartingAtRegistrationCount = embedDiscoveryInRegistration ? 1 @@ -503,21 +585,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { throw TestIrohTransportError.unsupported } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) async -> CmxIrohRelayTokenResponse { - relayIssueCount += 1 - if let relayIssueHook { - await relayIssueHook() - } - return CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-07-10T12:00:00.000Z", - refreshAfter: "2027-07-10T11:00:00.000Z", - relayFleet: HostRuntimeFixture.relayURLs - ) - } func revoke(bindingID: String) throws { revokedBindingIDs.append(bindingID) @@ -535,7 +602,6 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing { func observedPreparedRegistrations() -> [CmxIrohPreparedRegistration] { preparedRegistrations } - func observedRelayIssueCount() -> Int { relayIssueCount } func observedDiscoveryCount() -> Int { discoveryCount } func enqueueSubsequentRegistrationError( @@ -798,6 +864,7 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { private let healthContinuation: AsyncStream.Continuation private var closed = false private var closeCallCount = 0 + private var relayProfileUpdates: [CmxIrohEndpointRelayProfile] = [] init(identity: CmxIrohPeerIdentity) { peerIdentity = identity @@ -806,6 +873,14 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { healthContinuation = stream.continuation } + func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) { + relayProfileUpdates.append(profile) + } + + func observedRelayProfileUpdates() -> [CmxIrohEndpointRelayProfile] { + relayProfileUpdates + } + func identity() -> CmxIrohPeerIdentity { peerIdentity } func address() -> CmxIrohEndpointAddress { @@ -819,9 +894,11 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { try Task.checkCancellation() - if !connections.isEmpty { return connections.removeFirst() } + if !connections.isEmpty { + return CmxIrohEstablishedIncomingConnection(connections.removeFirst()) + } guard !closed else { return nil } let id = UUID() let connection = await withTaskCancellationHandler { @@ -830,10 +907,9 @@ actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { Task { await self.cancelAccept(id) } } try Task.checkCancellation() - return connection + return connection.map { CmxIrohEstablishedIncomingConnection($0) } } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { health } func isHealthy() -> Bool { true } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointCancellationTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointCancellationTests.swift index 1488f54a2503..c473300577f5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointCancellationTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointCancellationTests.swift @@ -62,8 +62,7 @@ private struct LibEndpointCancellationFixture { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) endpoint = CmxIrohLibEndpoint( driver: AttemptOnlyEndpoint(attempt: attempt), diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift index d8a29e91c0d8..0d2c5655942e 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift @@ -19,8 +19,7 @@ struct CmxIrohLibEndpointTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ), socketAddress: nil, relayMap: RelayMap.empty(), @@ -47,8 +46,7 @@ struct CmxIrohLibEndpointTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ), socketAddress: nil, relayMap: RelayMap.empty() @@ -217,7 +215,7 @@ struct CmxIrohLibEndpointTests { #expect(await endpoint.identity() == identity) try await endpoint.replaceRelayProfile( - CmxIrohEndpointRelayProfile(managedRelayURLs: [], relays: []) + CmxIrohEndpointRelayProfile(managedRelayURLs: []) ) await #expect(throws: CmxIrohLibError.unmanagedRelayURL(customURL)) { _ = try await concrete.endpointAddresses( @@ -425,8 +423,7 @@ struct CmxIrohLibEndpointTests { secretKey: CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init))), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], bindPolicy: bindPolicy, - managedRelayURLs: managedRelayURLs, - relays: [] + managedRelayURLs: managedRelayURLs ) return try await CmxIrohLibEndpointFactory( transportVerificationMode: transportVerificationMode @@ -439,8 +436,7 @@ struct CmxIrohLibEndpointTests { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init))), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] + managedRelayURLs: [] ) let driver = try await Endpoint.bind( options: CmxIrohLibEndpointFactory.endpointOptions( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift index 102907f98f3c..a6f25ef1b339 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift @@ -159,8 +159,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] + managedRelayURLs: [fixture.relayURL] ) ) _ = try await supervisor.activate() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift index 12fe8df70db6..272120d321d5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift @@ -173,8 +173,7 @@ extension CmxIrohOnlineAdmissionRegistryTests { configuration: try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] + managedRelayURLs: [fixture.relayURL] ) ) _ = try await supervisor.activate() diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift index fdff908519b6..5a5355ff7ebd 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift @@ -16,19 +16,7 @@ struct CmxIrohPersistenceLifecycleRaceTests { ) let fixture = try ClientRuntimeTestFixture() let binding = CmxIrohBrokerBindingMetadata(binding: fixture.binding) - let relayFleet = fixture.configuration.managedRelayURLs try await repository.saveBinding(binding, accountID: "account-a") - await store.suspendNextWrite() - let save = Task { - try await repository.saveRelayCredential( - fixture.relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: relayFleet, - now: fixture.now - ) - } - await store.waitUntilWriteIsSuspended() await store.suspendNextDeleteAll() let deactivate = Task { try await repository.deactivate() } @@ -40,8 +28,6 @@ struct CmxIrohPersistenceLifecycleRaceTests { ) } ) - await store.resumeSuspendedWrite() - await #expect(throws: CancellationError.self) { try await save.value } await store.waitUntilDeleteAllIsSuspended() await store.resumeSuspendedDeleteAll() try await deactivate.value diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift index c6614474744b..09b015236f32 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift @@ -323,8 +323,7 @@ struct CmxIrohPrivatePathTransportGateTests { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: fixture.privateKey.rawRepresentation), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] + managedRelayURLs: [fixture.relayURL] ) let supervisor = CmxIrohEndpointSupervisor( factory: CmxIrohLibEndpointFactory(transportVerificationMode: .directOnly), @@ -364,8 +363,7 @@ struct CmxIrohPrivatePathTransportGateTests { secretKey: CmxIrohSecretKey(bytes: fixture.acceptorSecretKey), alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], bindPolicy: .required(CmxIrohBindAddress(ipAddress: ipAddress, port: port)), - managedRelayURLs: [fixture.relayURL], - relays: [] + managedRelayURLs: [fixture.relayURL] ) return try await CmxIrohLibEndpointFactory( transportVerificationMode: .directOnly @@ -426,7 +424,8 @@ struct CmxIrohPrivatePathTransportGateTests { endpoint: any CmxIrohEndpoint, authorizer: CmxIrohAdmissionController ) async throws -> CmxIrohServerSession { - let connection = try #require(try await endpoint.accept()) + let incoming = try #require(try await endpoint.accept()) + let connection = try await incoming.establish() let session = try CmxIrohServerSession( connection: connection, authorizer: authorizer diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift index 9fa448ad5975..620b7594f3e0 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift @@ -383,6 +383,40 @@ struct CmxIrohRegistryContextProviderStalenessTests { #expect(context.dialPlan.publicPaths == [relay]) } + @Test + func refreshFailureAfterStalenessFallsBackToLastVerifiedSnapshot() async throws { + let fixture = try RegistryFixture() + let relay = try managedRelayHint(fixture) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [relay]), + pairGrantResponses: [try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + )] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + verifiedDiscovery: try fixture.discovery(targetHints: [relay]) + ) + // A timed-out dial marked the peer stale, so the next attempt must + // try one fresh fetch first. + await provider.noteDialFailure( + for: try fixture.request(hints: []), + dialPlan: try nonEmptyPlan(fixture, hints: [relay]), + failure: .timedOut + ) + await broker.setDiscoverError(CmxIrohTrustBrokerClientError.connectivity) + + // The forced refresh failed. Dialing with the last verified snapshot + // beats not dialing at all (cmux#9724): the staleness mark survives, + // so a later attempt still refetches once the broker recovers. + let context = try await provider.context(for: fixture.request(hints: [])) + + #expect(await broker.discoveryRequestCount() == 1) + #expect(context.dialPlan.publicPaths == [relay]) + } + @Test func cooldownDuringEmptyPlanRefetchKeepsResolvedContextInsteadOfSpinning() async throws { let fixture = try RegistryFixture() @@ -410,6 +444,74 @@ struct CmxIrohRegistryContextProviderStalenessTests { #expect(context.dialPlan.publicPaths.isEmpty) } + @Test + func nonTransientRefreshFailuresDoNotReuseLastGoodDiscovery() async throws { + let fixture = try RegistryFixture() + let relay = try managedRelayHint(fixture) + let grant = try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [relay]), + pairGrantResponses: [grant, grant, grant] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + verifiedDiscovery: try fixture.discovery(targetHints: [relay]) + ) + // A healthy dial consumes the one-shot verified snapshot, leaving the + // last-good authoritative snapshot armed for the next refresh. + _ = try await provider.context(for: fixture.request(hints: [])) + #expect(await broker.discoveryRequestCount() == 0) + + // Rollback/equivocation detection surfaces as `invalidResponse`; a + // revoked or replaced binding as a non-transient rejection. Neither + // may be masked by silently dialing with the last verified snapshot: + // both must fail closed toward re-discovery. + for error in [ + CmxIrohTrustBrokerClientError.invalidResponse, + CmxIrohTrustBrokerClientError.rejected( + statusCode: 403, + code: "binding_revoked" + ), + ] { + await broker.setDiscoverError(error) + await #expect(throws: error) { + try await provider.context(for: fixture.request(hints: [])) + } + } + } + + @Test + func connectivityRefreshFailuresStillReuseLastGoodDiscovery() async throws { + let fixture = try RegistryFixture() + let relay = try managedRelayHint(fixture) + let grant = try fixture.pairGrantResponse( + issuedAt: fixture.nowSeconds, + expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 + ) + let broker = ConfigurableRegistryBroker( + discovery: try fixture.discovery(targetHints: [relay]), + pairGrantResponses: [grant, grant] + ) + let provider = try await makeProvider( + fixture: fixture, + broker: broker, + verifiedDiscovery: try fixture.discovery(targetHints: [relay]) + ) + _ = try await provider.context(for: fixture.request(hints: [])) + #expect(await broker.discoveryRequestCount() == 0) + + // The transient class keeps the cmux#9724 behavior: dialing with the + // last verified snapshot beats not dialing at all while the broker + // recovers. + await broker.setDiscoverError(CmxIrohTrustBrokerClientError.connectivity) + let context = try await provider.context(for: fixture.request(hints: [])) + #expect(context.dialPlan.publicPaths == [relay]) + } + // MARK: - Support private func makeProvider( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift index d825dbe19b15..755b6b01f5bb 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift @@ -428,8 +428,7 @@ struct RegistryFixture: Sendable { let configuration = try CmxIrohEndpointConfiguration( secretKey: CmxIrohSecretKey(bytes: Data(repeating: 4, count: 32)), alpns: [Data("cmux/mobile/1".utf8)], - managedRelayURLs: [relayURL], - relays: [] + managedRelayURLs: [relayURL] ) let supervisor = CmxIrohEndpointSupervisor( factory: factory, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift deleted file mode 100644 index 30d77319e5f6..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift +++ /dev/null @@ -1,328 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohRelayCredentialCoordinatorTests { - @Test - func scheduledRefreshReplacesCredentialWithoutChangingEndpointIdentity() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let initialExpiry = fixture.now.addingTimeInterval(5 * 60) - let initialRefresh = initialExpiry.addingTimeInterval(-60) - let replacementExpiry = fixture.now.addingTimeInterval(13 * 60) - let replacementRefresh = replacementExpiry.addingTimeInterval(-60) - let expectedInitialClockEvent = TestRelayClock.Event.sleep(initialRefresh) - let expectedReplacementClockEvent = TestRelayClock.Event.sleep(replacementRefresh) - let broker = TestRelayTokenBroker(steps: [ - .response(try fixture.response( - tokens: ["ghi234", "jkl234"], - refreshAfter: replacementRefresh, - expiresAt: replacementExpiry - )), - ]) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - tokens: ["abc234", "def234"], - refreshAfter: initialRefresh, - expiresAt: initialExpiry - ) - ) - #expect(await clockEvents.next() == expectedInitialClockEvent) - - clock.advance(to: initialRefresh) - #expect(await clockEvents.next() == expectedReplacementClockEvent) - - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 2) - #expect(await endpoint.observedRelayUpdates().last?.map(\.token) == [ - "ghi234", - "jkl234", - ]) - #expect(await coordinator.credentialExpiresAt() == replacementExpiry) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func disabledAutomaticRefreshKeepsTheInstalledShortLivedCredential() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let broker = TestRelayTokenBroker(steps: [ - .response(try fixture.response( - tokens: ["replacement-a", "replacement-b"], - refreshAfter: fixture.now.addingTimeInterval(10 * 60), - expiresAt: fixture.now.addingTimeInterval(11 * 60) - )), - ]) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 }, - automaticRefreshEnabled: false - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - clock.advance(to: fixture.expiresAt.addingTimeInterval(1)) - try await coordinator.refreshIfNeeded() - for _ in 0 ..< 20 { await Task.yield() } - - #expect(clock.observedSleepDeadlines().isEmpty) - #expect(await broker.observedEndpointIDs().isEmpty) - #expect(await endpoint.observedRelayUpdates().count == 1) - #expect(await coordinator.credentialExpiresAt() == fixture.expiresAt) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func concurrentForegroundCatchUpSharesOneBrokerMint() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let expiry = fixture.now.addingTimeInterval(5 * 60) - let refresh = expiry.addingTimeInterval(-60) - let replacementExpiry = fixture.now.addingTimeInterval(15 * 60) - let replacementRefresh = replacementExpiry.addingTimeInterval(-60) - let gate = TestRelayIssueGate() - let broker = TestRelayTokenBroker( - steps: [.response(try fixture.response( - tokens: ["ghi234", "jkl567"], - refreshAfter: replacementRefresh, - expiresAt: replacementExpiry - ))], - issueHook: { count in - if count == 1 { await gate.park() } - } - ) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - tokens: ["abc234", "def567"], - refreshAfter: refresh, - expiresAt: expiry - ) - ) - clock.setNowWithoutResuming(expiry.addingTimeInterval(1)) - - let first = Task { try await coordinator.refreshIfNeeded() } - await gate.waitUntilParked() - let second = Task { try await coordinator.refreshIfNeeded() } - for _ in 0 ..< 20 { await Task.yield() } - - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - - await gate.release() - try await first.value - try await second.value - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 2) - await coordinator.deactivate() - } - - @Test - func foregroundCatchUpRefreshesCredentialAfterSuspensionPastDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let initialExpiry = fixture.now.addingTimeInterval(5 * 60) - let initialRefresh = initialExpiry.addingTimeInterval(-60) - let replacementExpiry = fixture.now.addingTimeInterval(15 * 60) - let replacementRefresh = replacementExpiry.addingTimeInterval(-60) - let broker = TestRelayTokenBroker(steps: [ - .response(try fixture.response( - tokens: ["ghi234", "jkl567"], - refreshAfter: replacementRefresh, - expiresAt: replacementExpiry - )), - ]) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - tokens: ["abc234", "def567"], - refreshAfter: initialRefresh, - expiresAt: initialExpiry - ) - ) - clock.setNowWithoutResuming(initialExpiry.addingTimeInterval(1)) - - try await coordinator.refreshIfNeeded() - - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 2) - #expect(await endpoint.observedRelayUpdates().last?.map(\.token) == [ - "ghi234", - "jkl567", - ]) - #expect(await coordinator.credentialExpiresAt() == replacementExpiry) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func foregroundCatchUpDoesNotMintBeforeRefreshDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: []) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - - try await coordinator.refreshIfNeeded() - - #expect(await broker.observedEndpointIDs().isEmpty) - #expect(await endpoint.observedRelayUpdates().count == 1) - await coordinator.deactivate() - } - - @Test - func refreshFailureRetriesBeforeInstalledCredentialSafetyDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.failure]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let expiresAt = fixture.now.addingTimeInterval(5 * 60) - let refreshAfter = expiresAt.addingTimeInterval(-60) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - refreshAfter: refreshAfter, - expiresAt: expiresAt - ) - ) - #expect(await clockEvents.next() == .sleep(refreshAfter)) - - clock.advance(to: refreshAfter) - - guard case let .sleep(retryDeadline) = await clockEvents.next() else { - Issue.record("Expected a relay retry before credential expiry") - return - } - #expect(retryDeadline == expiresAt.addingTimeInterval(-30)) - #expect(retryDeadline < expiresAt) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - await coordinator.deactivate() - } - - @Test - func mismatchedBootstrapFleetNeverMutatesEndpoint() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: [.failure]), - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - let incomplete = try fixture.response(relayURLs: [fixture.relayURLs[0]]) - - await #expect( - throws: CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - ) { - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: incomplete - ) - } - await coordinator.deactivate() - - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - } -} - -private actor TestRelayIssueGate { - private var isParked = false - private var parkContinuation: CheckedContinuation? - private var parkedWaiters: [CheckedContinuation] = [] - - func park() async { - isParked = true - let waiters = parkedWaiters - parkedWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - await withCheckedContinuation { parkContinuation = $0 } - } - - func waitUntilParked() async { - guard !isParked else { return } - await withCheckedContinuation { parkedWaiters.append($0) } - } - - func release() { - parkContinuation?.resume() - parkContinuation = nil - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift deleted file mode 100644 index c258d8206c01..000000000000 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift +++ /dev/null @@ -1,671 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohRelayCredentialCoordinatorTests { - @Test - func hostAndClientRefreshSlotsStaySeparatedAcrossCredentialCycles() throws { - let hostIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "1a", count: 32) - ) - let clientIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "b7", count: 32) - ) - let hostSchedule = CmxIrohRelayRefreshSchedule( - role: .host, - endpointIdentity: hostIdentity - ) - let clientSchedule = CmxIrohRelayRefreshSchedule( - role: .client, - endpointIdentity: clientIdentity - ) - let now = Date(timeIntervalSince1970: 1_700_000_000) - var hostSeconds: [Int] = [] - var clientSeconds: [Int] = [] - - for cycle in 1 ... 8 { - let refreshAfter = now.addingTimeInterval(TimeInterval(cycle * 240)) - let hostDeadline = hostSchedule.deadline( - now: now, - refreshAfter: refreshAfter - ) - let clientDeadline = clientSchedule.deadline( - now: now, - refreshAfter: refreshAfter - ) - let hostSecond = Int(hostDeadline.timeIntervalSince1970) % 60 - let clientSecond = Int(clientDeadline.timeIntervalSince1970) % 60 - hostSeconds.append(hostSecond) - clientSeconds.append(clientSecond) - - #expect((0 ... 14).contains(hostSecond)) - #expect((30 ... 44).contains(clientSecond)) - #expect(hostDeadline >= now) - #expect(clientDeadline >= now) - #expect(hostDeadline <= refreshAfter) - #expect(clientDeadline <= refreshAfter) - } - - #expect(Set(hostSeconds).count == 1) - #expect(Set(clientSeconds).count == 1) - } - - @Test - func refreshSlotsSpreadEndpointsWithinEachRole() throws { - let refreshAfter = Date(timeIntervalSince1970: 1_700_000_240) - let now = refreshAfter.addingTimeInterval(-240) - let hostSlots = try (0 ..< 16).map { index in - let identity = try CmxIrohPeerIdentity( - endpointID: String(format: "%064x", index + 1) - ) - return Int( - CmxIrohRelayRefreshSchedule(role: .host, endpointIdentity: identity) - .deadline(now: now, refreshAfter: refreshAfter) - .timeIntervalSince1970 - ) % 60 - } - let clientSlots = try (0 ..< 16).map { index in - let identity = try CmxIrohPeerIdentity( - endpointID: String(format: "%064x", index + 1) - ) - return Int( - CmxIrohRelayRefreshSchedule(role: .client, endpointIdentity: identity) - .deadline(now: now, refreshAfter: refreshAfter) - .timeIntervalSince1970 - ) % 60 - } - - #expect(Set(hostSlots).count > 1) - #expect(hostSlots.allSatisfy { (0 ... 14).contains($0) }) - #expect(Set(clientSlots).count > 1) - #expect(clientSlots.allSatisfy { (30 ... 44).contains($0) }) - } - - @Test - func bootstrapInstallsCompleteFleetBeforeSleepingUntilRefresh() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: []) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let response = try fixture.response() - let installs = TestRelayCredentialInstallRecorder() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 }, - credentialDidInstall: { response in - await installs.record(response) - } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: response - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay refresh sleep") - return - } - #expect(deadline == fixture.refreshAfter) - let updates = await endpoint.observedRelayUpdates() - #expect(updates.count == 1) - #expect(updates[0].map(\.url) == fixture.relayURLs) - #expect(await coordinator.credentialExpiresAt() == fixture.expiresAt) - await installs.waitForCount(1) - #expect(await installs.values() == [response]) - #expect(await broker.observedEndpointIDs().isEmpty) - await coordinator.deactivate() - #expect(await clockEvents.next() == .cancelled) - } - - @Test - func stalledCredentialPersistenceDoesNotBlockRefreshScheduling() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let persistence = TestRelayCredentialPersistenceGate() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 }, - credentialDidInstall: { response in - await persistence.persist(response) - } - ) - - let activation = Task { - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - } - await persistence.waitUntilStarted() - for _ in 0 ..< 20 { await Task.yield() } - - #expect(clock.observedSleepDeadlines() == [fixture.refreshAfter]) - #expect(await endpoint.observedRelayUpdates().count == 1) - - await persistence.resume() - try await activation.value - await coordinator.deactivate() - } - - @Test - func bootstrapKeepsEachTokenAssociatedWithItsSignedRelayURL() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response(tokens: ["abc234", "def567"]) - ) - - let updates = await endpoint.observedRelayUpdates() - #expect(updates.count == 1) - #expect(updates[0].map(\.url) == fixture.relayURLs) - #expect(updates[0].map(\.token) == ["abc234", "def567"]) - await coordinator.deactivate() - } - - @Test - func selectedManagedSubsetInstallsOnlyChosenRelayAfterFullFleetValidation() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let selectedURL = fixture.relayURLs[1] - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - selectedRelayURLs: [selectedURL], - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - - let profiles = await endpoint.observedRelayProfileUpdates() - #expect(profiles.count == 1) - #expect(profiles[0].allowedRelayURLs == [selectedURL]) - #expect(profiles[0].managedRelays.map(\.url) == [selectedURL]) - #expect(await endpoint.observedRelayUpdates().isEmpty) - await coordinator.deactivate() - } - - @Test - func missingBootstrapRefreshesImmediatelyAndInstallsWithoutRebinding() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.response(try fixture.response())]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay refresh sleep") - return - } - #expect(deadline == fixture.refreshAfter) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 1) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func transientMintFailureKeepsEndpointAliveAndBacksOff() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.failure]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay retry sleep") - return - } - #expect(deadline == fixture.now.addingTimeInterval(30)) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func requiredInitialCredentialWaitsThroughTransientMintFailure() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [ - .failure, - .response(try fixture.response()), - ]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let completions = TestRelayActivationCompletionRecorder() - let installs = TestRelayCredentialInstallRecorder() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retrySchedule: CmxIrohRetrySchedule( - initialDelay: 1, - maximumDelay: 1, - jitterFraction: 0 - ), - retryJitter: { 0 }, - credentialDidInstall: { response in - await installs.record(response) - } - ) - let activation = Task { - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - waitForInitialCredential: true - ) - await completions.record() - } - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the initial relay retry sleep") - return - } - for _ in 0 ..< 20 { await Task.yield() } - #expect(deadline == fixture.now.addingTimeInterval(1)) - #expect(await completions.count() == 0) - - clock.advance(to: deadline) - try await activation.value - await installs.waitForCount(1) - #expect(await completions.count() == 1) - #expect(await broker.observedEndpointIDs() == [fixture.identity, fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 1) - await coordinator.deactivate() - } - - @Test - func rateLimitRetryNeverPrecedesValidatedServerFloor() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: [.rateLimited(600)]), - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - let clockEvent = await clockEvents.next() - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(clockEvent == .sleep(fixture.now.addingTimeInterval(600))) - await coordinator.deactivate() - } - - @Test - func restoredCooldownRetryNeverPrecedesPersistedServerFloor() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: [.cooldown(600)]), - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - let clockEvent = await clockEvents.next() - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(clockEvent == .sleep(fixture.now.addingTimeInterval(600))) - await coordinator.deactivate() - } - -} - -private actor TestRelayActivationCompletionRecorder { - private var completionCount = 0 - - func record() { - completionCount += 1 - } - - func count() -> Int { - completionCount - } -} - -private actor TestRelayCredentialInstallRecorder { - private var responses: [CmxIrohRelayTokenResponse] = [] - private var waiters: [(Int, CheckedContinuation)] = [] - - func record(_ response: CmxIrohRelayTokenResponse) { - responses.append(response) - let ready = waiters.filter { responses.count >= $0.0 } - waiters.removeAll { responses.count >= $0.0 } - for (_, continuation) in ready { continuation.resume() } - } - - func values() -> [CmxIrohRelayTokenResponse] { - responses - } - - func waitForCount(_ count: Int) async { - guard responses.count < count else { return } - await withCheckedContinuation { continuation in - waiters.append((count, continuation)) - } - } -} - -private actor TestRelayCredentialPersistenceGate { - private var started = false - private var startWaiters: [CheckedContinuation] = [] - private var persistenceContinuation: CheckedContinuation? - - func persist(_: CmxIrohRelayTokenResponse) async { - started = true - let waiters = startWaiters - startWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - await withCheckedContinuation { continuation in - persistenceContinuation = continuation - } - } - - func waitUntilStarted() async { - guard !started else { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } - - func resume() { - persistenceContinuation?.resume() - persistenceContinuation = nil - } -} - -actor TestRelayTokenBroker: CmxIrohRelayTokenServing { - enum Step: Sendable { - case response(CmxIrohRelayTokenResponse) - case failure - case rateLimited(Int) - case cooldown(Int) - } - - private var steps: [Step] - private var endpointIDs: [CmxIrohPeerIdentity] = [] - private var issueCount = 0 - private let issueHook: (@Sendable (_ count: Int) async -> Void)? - - init( - steps: [Step], - issueHook: (@Sendable (_ count: Int) async -> Void)? = nil - ) { - self.steps = steps - self.issueHook = issueHook - } - - func issueRelayToken( - bindingID _: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - endpointIDs.append(endpointID) - issueCount += 1 - await issueHook?(issueCount) - guard !steps.isEmpty else { throw TestRelayCoordinatorError.noResponse } - switch steps.removeFirst() { - case let .response(response): - return response - case .failure: - throw TestRelayCoordinatorError.transient - case let .rateLimited(retryAfterSeconds): - throw CmxIrohTrustBrokerClientError.rateLimited( - code: "rate_limited", - retryAfterSeconds: retryAfterSeconds - ) - case let .cooldown(retryAfterSeconds): - throw CmxIrohBrokerCooldownError( - retryAfterSeconds: retryAfterSeconds - ) - } - } - - func observedEndpointIDs() -> [CmxIrohPeerIdentity] { - endpointIDs - } -} - -final class TestRelayClock: CmxIrohRelayClock, @unchecked Sendable { - enum Event: Equatable, Sendable { - case sleep(Date) - case cancelled - } - - private let lock = NSLock() - private var currentDate: Date - private var sleepers: [UUID: CheckedContinuation] = [:] - private var sleepDeadlines: [Date] = [] - private let eventStream: AsyncStream - private let continuation: AsyncStream.Continuation - - init(now: Date) { - currentDate = now - let events = AsyncStream.makeStream() - eventStream = events.stream - continuation = events.continuation - } - - func now() -> Date { - lock.withLock { currentDate } - } - - func sleep(until deadline: Date) async throws { - lock.withLock { sleepDeadlines.append(deadline) } - continuation.yield(.sleep(deadline)) - let id = UUID() - try await withTaskCancellationHandler { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { sleeper in - lock.withLock { - sleepers[id] = sleeper - } - if Task.isCancelled { - cancelSleep(id: id) - } - } - } onCancel: { - cancelSleep(id: id) - } - } - - func advance(to date: Date) { - let pending = lock.withLock { () -> [CheckedContinuation] in - currentDate = date - defer { sleepers.removeAll() } - return Array(sleepers.values) - } - for sleeper in pending { - sleeper.resume() - } - } - - func setNowWithoutResuming(_ date: Date) { - lock.withLock { currentDate = date } - } - - func events() -> AsyncStream { - eventStream - } - - func observedSleepDeadlines() -> [Date] { - lock.withLock { sleepDeadlines } - } - - private func cancelSleep(id: UUID) { - let sleeper = lock.withLock { sleepers.removeValue(forKey: id) } - guard let sleeper else { return } - continuation.yield(.cancelled) - sleeper.resume(throwing: CancellationError()) - } -} - -private enum TestRelayCoordinatorError: Error { - case noResponse - case transient -} - -struct RelayCoordinatorFixture: Sendable { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let bindingID = "123e4567-e89b-42d3-a456-426614174010" - let identity: CmxIrohPeerIdentity - let relayURLs = [ - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ] - - var refreshAfter: Date { - now.addingTimeInterval(12 * 60 * 60) - } - - var expiresAt: Date { - now.addingTimeInterval(24 * 60 * 60) - } - - init() throws { - identity = try CmxIrohPeerIdentity(endpointID: String(repeating: "ab", count: 32)) - } - - func activeSupervisor( - endpoint: TestIrohEndpoint - ) async throws -> CmxIrohEndpointSupervisor { - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: Set(relayURLs), - relays: [] - ) - ) - _ = try await supervisor.activate() - return supervisor - } - - func response( - relayURLs: [String]? = nil, - tokens: [String]? = nil, - refreshAfter: Date? = nil, - expiresAt: Date? = nil - ) throws -> CmxIrohRelayTokenResponse { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let urls = relayURLs ?? self.relayURLs - if let tokens { - guard tokens.count == urls.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohRelayTokenResponse( - credentials: zip(urls, tokens).map { url, token in - CmxIrohManagedRelayCredential( - relayURL: url, - token: token, - expiresAt: formatter.string( - from: expiresAt ?? self.expiresAt - ), - refreshAfter: formatter.string( - from: refreshAfter ?? self.refreshAfter - ) - ) - } - ) - } - let object: [String: Any] = [ - "token": "abc234", - "expires_at": formatter.string(from: expiresAt ?? self.expiresAt), - "refresh_after": formatter.string(from: refreshAfter ?? self.refreshAfter), - "relay_fleet": urls, - ] - return try JSONDecoder().decode( - CmxIrohRelayTokenResponse.self, - from: JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - ) - } -} diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift index fb419cb6085b..19488096cef5 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift @@ -6,16 +6,12 @@ import Testing @Suite(.serialized) struct CmxIrohRelayPolicyBrokerTests { @Test - func bootstrapAcceptsRevisionZeroAndNullableToken() async throws { + func policyFetchUsesTheCredentialFreePolicyRoute() async throws { let transport = RecordingBrokerTransport(responses: [ .json( status: 200, body: """ { - "token": null, - "expiresAt": 1782000300, - "ttlSeconds": 300, - "relays": ["https://usc1.relay.cmux.dev"], "policy": "aaa.bbb.ccc", "preference": {"mode":"automatic"}, "preferenceRevision": 0 @@ -25,16 +21,16 @@ struct CmxIrohRelayPolicyBrokerTests { ]) let client = try makeClient(transport: transport) - let response = try await client.issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity(endpointID: Self.endpointID) - ) + let response = try await client.fetchRelayPolicy() - #expect(response.relayToken == nil) - #expect(response.relayPolicy.preference == .automatic) - #expect(response.relayPolicy.preferenceRevision == 0) + #expect(response.preference == .automatic) + #expect(response.preferenceRevision == 0) let request = try #require(await transport.requests().first) - #expect(request.url?.path == "/api/relay/token") - #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/relay/policy") + #expect(request.httpMethod == "GET") + // Tokenless transport: the policy fetch carries no endpoint binding + // payload and mints nothing. + #expect(request.httpBody == nil) } @Test diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift index a7c047ebbf9b..1c66830e6c9e 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceRefreshTests.swift @@ -3,19 +3,17 @@ import Foundation import Testing @testable import CmuxIrohTransport -private actor RefreshBootstrapBroker: CmxIrohRelayPolicyServing { - private let bootstrap: CmxIrohRelayBootstrapResponse - private(set) var bootstrapRequestCount = 0 +private actor RefreshPolicyBroker: CmxIrohRelayPolicyServing { + private let response: CmxIrohRelayPolicyResponse + private(set) var policyRequestCount = 0 - init(bootstrap: CmxIrohRelayBootstrapResponse) { - self.bootstrap = bootstrap + init(response: CmxIrohRelayPolicyResponse) { + self.response = response } - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { - bootstrapRequestCount += 1 - return bootstrap + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { + policyRequestCount += 1 + return response } func relayPreference() async throws -> CmxIrohRelayPreferenceResponse { @@ -31,18 +29,17 @@ private actor RefreshBootstrapBroker: CmxIrohRelayPolicyServing { @Suite struct CmxIrohRelayPolicyServiceRefreshTests { + /// A refresh installs the signed policy and yields a tokenless managed + /// profile: every allowed relay is active with no client credential, + /// because relay admission is the relay's server-side allow hook. @Test - func refreshWithCredentialReturnsMintedCredentialWithEffectivePolicy() async throws { + func refreshInstallsTokenlessManagedProfile() async throws { let fixture = RelayPolicyServiceTestFixture() - let credential = fixture.relayCredential() - let broker = RefreshBootstrapBroker( - bootstrap: CmxIrohRelayBootstrapResponse( - relayToken: credential, - relayPolicy: try CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .automatic, - preferenceRevision: 1 - ) + let broker = RefreshPolicyBroker( + response: try CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 ) ) let service = CmxIrohRelayPolicyService( @@ -54,35 +51,123 @@ struct CmxIrohRelayPolicyServiceRefreshTests { broker: broker ) - let outcome = try await service.refreshWithCredential( - endpointID: try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ), + let effective = try await service.refresh( accountID: "account-a", trustRoot: fixture.firstTrustRoot, now: fixture.now ) - #expect(outcome.relayCredential == credential) - #expect(outcome.effective.endpointRelayProfile.allowedRelayURLs - == Set(fixture.relayURLs)) - #expect(await broker.bootstrapRequestCount == 1) + #expect(effective.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) + #expect(effective.endpointRelayProfile.activeRelays.count == fixture.relayURLs.count) + #expect(effective.endpointRelayProfile.activeRelays.allSatisfy { + $0.authenticationToken == nil + }) + #expect(await broker.policyRequestCount == 1) } + /// Persistent refresh failure must be visible host state (cmux#10873): + /// every failed authenticated refresh round extends a published failure + /// streak, whether the broker fetch itself failed or its response failed + /// verification, and the streak start survives later failures. @Test - func refreshDelegatesToRefreshWithCredential() async throws { + func refreshFailuresPublishGrowingStreak() async throws { let fixture = RelayPolicyServiceTestFixture() - let broker = RefreshBootstrapBroker( - bootstrap: CmxIrohRelayBootstrapResponse( - relayToken: nil, - relayPolicy: try CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .automatic, - preferenceRevision: 1 + let broker = ScriptedPolicyBroker(results: [ + .failure(TestRelayPolicyTransportError.offline), + .failure(TestRelayPolicyTransportError.offline), + .failure(TestRelayPolicyTransportError.offline), + ]) + let service = Self.service(broker: broker) + let firstFailureAt = fixture.now + + for attempt in 1 ... 3 { + await #expect(throws: TestRelayPolicyTransportError.self) { + try await service.refresh( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: firstFailureAt.addingTimeInterval(TimeInterval(attempt - 1)) ) + } + let snapshot = await service.diagnosticsSnapshot() + #expect(snapshot.refreshFailingSince == firstFailureAt) + #expect(snapshot.consecutiveRefreshFailures == attempt) + } + let snapshot = await service.diagnosticsSnapshot() + #expect( + snapshot.consecutiveRefreshFailures + >= CmxIrohRelayPolicyService.persistentRefreshFailureThreshold + ) + } + + /// A refresh whose broker response fails signature verification is a + /// refresh failure too: the streak grows exactly as for a fetch failure. + @Test + func rejectedPolicyCountsTowardStreak() async throws { + let fixture = RelayPolicyServiceTestFixture() + let broker = ScriptedPolicyBroker(results: [ + .success(try CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1, signer: 2), + preference: .automatic, + preferenceRevision: 1 + )), + ]) + let service = Self.service(broker: broker) + + await #expect(throws: (any Error).self) { + // Signed by a key absent from the trust root. + try await service.refresh( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: fixture.now ) + } + let snapshot = await service.diagnosticsSnapshot() + #expect(snapshot.refreshFailingSince == fixture.now) + #expect(snapshot.consecutiveRefreshFailures == 1) + } + + /// A successful refresh clears the streak, so recovery is visible on the + /// same surface that reported the outage. + @Test + func successfulRefreshClearsStreak() async throws { + let fixture = RelayPolicyServiceTestFixture() + let broker = ScriptedPolicyBroker(results: [ + .failure(TestRelayPolicyTransportError.offline), + .failure(TestRelayPolicyTransportError.offline), + .success(try CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 + )), + ]) + let service = Self.service(broker: broker) + + for _ in 1 ... 2 { + await #expect(throws: TestRelayPolicyTransportError.self) { + try await service.refresh( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: fixture.now + ) + } + } + #expect(await service.diagnosticsSnapshot().consecutiveRefreshFailures == 2) + + let effective = try await service.refresh( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: fixture.now ) - let service = CmxIrohRelayPolicyService( + #expect(effective.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) + let snapshot = await service.diagnosticsSnapshot() + #expect(snapshot.refreshFailingSince == nil) + #expect(snapshot.consecutiveRefreshFailures == 0) + } + + private static func service( + broker: any CmxIrohRelayPolicyServing + ) -> CmxIrohRelayPolicyService { + CmxIrohRelayPolicyService( policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), preferenceStore: CmxIrohRelayPreferenceStore(secureStore: TestSecureCredentialStore()), credentialStore: CmxIrohCustomRelayCredentialStore( @@ -90,17 +175,33 @@ struct CmxIrohRelayPolicyServiceRefreshTests { ), broker: broker ) + } +} - let effective = try await service.refresh( - endpointID: try CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) +private enum TestRelayPolicyTransportError: Error { + case offline +} - #expect(effective.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) - #expect(await broker.bootstrapRequestCount == 1) +/// Serves one scripted result per fetch, repeating the last one. +private actor ScriptedPolicyBroker: CmxIrohRelayPolicyServing { + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { + let result = results.count > 1 ? results.removeFirst() : results[0] + return try result.get() + } + + func relayPreference() async throws -> CmxIrohRelayPreferenceResponse { + throw CmxIrohRelayPolicyServiceError.brokerUnavailable + } + + func updateRelayPreference( + _: CmxIrohRelayPreferenceUpdateRequest + ) async throws -> CmxIrohRelayPreferenceResponse { + throw CmxIrohRelayPolicyServiceError.brokerUnavailable } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift index bbc5087fd0bf..47955980654c 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift @@ -85,14 +85,12 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect(effective.source == .customUnavailable) #expect(effective.endpointRelayProfile.allowedRelayURLs.isEmpty) #expect(effective.endpointRelayProfile.activeRelays.isEmpty) - #expect(effective.relayBootstrap == nil) #expect(effective.missingCredentialRelayIDs == [authenticatedRelay.id]) #expect(await stores.service.diagnosticsSnapshot().failure == .missingCustomCredential) } @@ -122,7 +120,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -173,7 +170,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect( @@ -192,7 +188,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -280,7 +275,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) await preferenceSecureStore.setUnavailable(true) @@ -289,7 +283,6 @@ extension CmxIrohRelayPolicyServiceTests { updated, accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -342,7 +335,6 @@ extension CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) let oldActive = try await service.setStaticCredential( diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift index 272faa3df11a..aa3c9eba62a3 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift @@ -20,7 +20,6 @@ struct CmxIrohRelayPolicyServiceTests { response: response, accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -28,7 +27,6 @@ struct CmxIrohRelayPolicyServiceTests { #expect(effective.staleRelayIDs == ["removed-relay"]) #expect(effective.endpointRelayProfile.allowedRelayURLs == [fixture.relayURLs[0]]) #expect(effective.managedSnapshot?.relays.map(\.id) == ["cmux-us"]) - #expect(effective.relayBootstrap == fixture.relayCredential()) let stored = try #require( try await stores.preferenceStore.load(accountID: "account-a") ) @@ -44,13 +42,11 @@ struct CmxIrohRelayPolicyServiceTests { response: fullyStale, accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect(directOnly.source == .managedUnavailable) #expect(directOnly.effectivePreference == nil) #expect(directOnly.endpointRelayProfile.allowedRelayURLs.isEmpty) - #expect(directOnly.relayBootstrap == nil) #expect(await service.diagnosticsSnapshot().failure == .staleManagedSelection) } @@ -76,7 +72,6 @@ struct CmxIrohRelayPolicyServiceTests { response: response, accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect(missing.source == .customUnavailable) @@ -128,7 +123,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: nil, now: fixture.now ) @@ -164,7 +158,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: nil, now: fixture.now ) @@ -199,7 +192,6 @@ struct CmxIrohRelayPolicyServiceTests { _ = await service.restore( accountID: "account-a", trustRoot: trustRoot, - relayCredential: nil, now: Date() ) } @@ -228,7 +220,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -241,7 +232,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) } @@ -263,7 +253,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -276,7 +265,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) } @@ -288,6 +276,62 @@ struct CmxIrohRelayPolicyServiceTests { #expect(await stores.service.diagnosticsSnapshot().failure == .preferenceRollback) } + @Test + func restoreFailsClosedBeyondTheExpiredPolicyReuseGrace() async throws { + let fixture = RelayPolicyServiceTestFixture() + let stores = makeStores() + _ = try await stores.service.install( + response: CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 + ), + accountID: "account-a", + trustRoot: fixture.firstTrustRoot, + now: fixture.now + ) + + let expired = await stores.service.restore( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: fixture.now.addingTimeInterval( + 3_600 + CmxIrohRelayPolicyService.defaultExpiredPolicyReuseGrace + 1 + ) + ) + + #expect(expired.source == .managedUnavailable) + #expect(expired.endpointRelayProfile.allowedRelayURLs.isEmpty) + #expect(await stores.service.diagnosticsSnapshot().failure == .policyExpired) + } + + @Test + func expiredPolicyGraceNeverBypassesSignatureVerification() async throws { + let fixture = RelayPolicyServiceTestFixture() + let stores = makeStores() + _ = try await stores.service.install( + response: CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 + ), + accountID: "account-a", + trustRoot: fixture.firstTrustRoot, + now: fixture.now + ) + + // A trust root that rejects the cached policy's signing key must + // fail closed even inside the expiry grace window: the grace covers + // only time, never a rejected credential. + let rejected = await stores.service.restore( + accountID: "account-a", + trustRoot: try fixture.secondTrustRoot, + now: fixture.now.addingTimeInterval(3_600 + 300) + ) + + #expect(rejected.source == .managedUnavailable) + #expect(rejected.endpointRelayProfile.allowedRelayURLs.isEmpty) + } + @Test func cacheRestoresUntilSignedExpiryAndSupportsStagedKeyRotation() async throws { let fixture = RelayPolicyServiceTestFixture() @@ -300,7 +344,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.rotatedTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) _ = try await stores.service.install( @@ -311,27 +354,59 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.rotatedTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) let restored = await stores.service.restore( accountID: "account-a", trustRoot: try fixture.secondTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) #expect(restored.usedCachedPolicy) #expect(restored.managedSnapshot?.policy.sequence == 2) - let expired = await stores.service.restore( + // Immediately past the signed expiry the last-good policy stays + // dialable inside the bounded reuse grace (cmux#10375); the graced + // state is reported as `.policyExpired` without zeroing routes. + let graced = await stores.service.restore( accountID: "account-a", trustRoot: try fixture.secondTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now.addingTimeInterval(3_600) ) - #expect(expired.source == .managedUnavailable) - #expect(expired.endpointRelayProfile.allowedRelayURLs.isEmpty) + #expect(graced.source == .managed) + #expect(graced.usedCachedPolicy) + #expect(!graced.endpointRelayProfile.allowedRelayURLs.isEmpty) + #expect(await stores.service.diagnosticsSnapshot().failure == .policyExpired) + } + + @Test + func restoreKeepsRecentlyExpiredLastGoodPolicyRoutesForDialing() async throws { + let fixture = RelayPolicyServiceTestFixture() + let stores = makeStores() + _ = try await stores.service.install( + response: CmxIrohRelayPolicyResponse( + policy: fixture.token(sequence: 1), + preference: .automatic, + preferenceRevision: 1 + ), + accountID: "account-a", + trustRoot: fixture.firstTrustRoot, + now: fixture.now + ) + + // The one-hour policy expired five minutes ago and the broker refresh + // failed (cmux#10375). Restoring must keep the last-good catalog + // dialable instead of publishing a zero-route managed profile; the + // relay itself remains the authority on credential validity. + let restored = await stores.service.restore( + accountID: "account-a", + trustRoot: try fixture.firstTrustRoot, + now: fixture.now.addingTimeInterval(3_600 + 300) + ) + + #expect(restored.source == .managed) + #expect(restored.usedCachedPolicy) + #expect(restored.endpointRelayProfile.allowedRelayURLs == Set(fixture.relayURLs)) #expect(await stores.service.diagnosticsSnapshot().failure == .policyExpired) } @@ -347,7 +422,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) @@ -360,7 +434,6 @@ struct CmxIrohRelayPolicyServiceTests { ), accountID: "account-a", trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), now: fixture.now ) } @@ -498,9 +571,7 @@ actor RelayPolicyServiceBroker: CmxIrohRelayPolicyServing { self.responses = responses } - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { throw Failure.unsupported } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift index 3b8a2909d29d..a6181b9ac576 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift @@ -25,6 +25,59 @@ struct CmxIrohRelayPolicyTests { #expect(trustRoot?.keys.map(\.keyID) == ["policy-current", "policy-next"]) } + /// A build configuration that does not stage a key for an optional plist + /// slot leaves both substitution variables undefined, which the Info.plist + /// build expands to empty strings. The exactly-empty record is an unused + /// slot, not a malformed trust root. + @Test + func appPinnedTrustRootSkipsUnusedEmptyRotationSlot() throws { + let current = Curve25519.Signing.PrivateKey() + let next = Curve25519.Signing.PrivateKey() + let trustRoot = CmxIrohRelayPolicyTrustRoot.appPinned(infoDictionary: [ + "CMUXIrohRelayPolicyTrustKeys": [ + [ + "keyID": "policy-current", + "publicKeyBase64": current.publicKey.rawRepresentation.base64EncodedString(), + ], + [ + "keyID": "policy-next", + "publicKeyBase64": next.publicKey.rawRepresentation.base64EncodedString(), + ], + ["keyID": "", "publicKeyBase64": ""], + ], + ]) + + #expect(trustRoot?.keys.map(\.keyID) == ["policy-current", "policy-next"]) + } + + /// A half-filled slot (empty key ID with a non-empty key, or the reverse) + /// is a misconfiguration, not an unused slot, and must fail closed. + @Test + func appPinnedTrustRootFailsClosedForHalfFilledRotationSlot() throws { + let current = Curve25519.Signing.PrivateKey() + let orphanKey = Curve25519.Signing.PrivateKey() + let currentRecord = [ + "keyID": "policy-current", + "publicKeyBase64": current.publicKey.rawRepresentation.base64EncodedString(), + ] + #expect(CmxIrohRelayPolicyTrustRoot.appPinned(infoDictionary: [ + "CMUXIrohRelayPolicyTrustKeys": [ + currentRecord, + [ + "keyID": "", + "publicKeyBase64": orphanKey.publicKey.rawRepresentation + .base64EncodedString(), + ], + ], + ]) == nil) + #expect(CmxIrohRelayPolicyTrustRoot.appPinned(infoDictionary: [ + "CMUXIrohRelayPolicyTrustKeys": [ + currentRecord, + ["keyID": "policy-extra", "publicKeyBase64": ""], + ], + ]) == nil) + } + @Test func appPinnedTrustRootFailsClosedForPartialRotationConfiguration() throws { let current = Curve25519.Signing.PrivateKey() @@ -269,6 +322,41 @@ struct CmxIrohRelayPolicyTests { ) } + @Test + func expiredPolicyReuseGraceIsClampedToTheCacheMaximum() async throws { + let fixture = try Fixture() + let cache = CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()) + // The fixture token carries the default one-hour signed validity. + _ = try await cache.install( + signedPolicy: fixture.token(sequence: 1), + trustRoot: fixture.trustRoot, + now: fixture.now + ) + + // An unbounded caller grace must not make the expired signed policy + // reusable indefinitely: past the cache's own maximum the load fails + // closed as expired. + let farPastExpiry = fixture.now.addingTimeInterval( + 3_600 + CmxIrohRelayPolicyCache.maximumExpiredPolicyReuseGrace + 60 + ) + await #expect(throws: CmxIrohRelayPolicyError.expired) { + try await cache.load( + trustRoot: fixture.trustRoot, + now: farPastExpiry, + expiredPolicyReuseGrace: .infinity + ) + } + + // Inside the clamp the same unbounded request still grants the + // bounded fail-open window (cmux#10375). + let graced = try await cache.load( + trustRoot: fixture.trustRoot, + now: fixture.now.addingTimeInterval(3_600 + 60), + expiredPolicyReuseGrace: .infinity + ) + #expect(graced?.sequence == 1) + } + @Test func corruptPolicyCacheCannotEraseTheRollbackFloor() async throws { let fixture = try Fixture() @@ -298,8 +386,11 @@ struct CmxIrohRelayPolicyTests { #expect(await store.recordCount() == 1) } + /// A verified managed selection installs tokenless: every selected relay + /// is active with no client credential, because relay admission is the + /// relay's server-side allow hook, not a token. @Test - func endpointProfileRequiresExactCredentialsForVerifiedSelection() throws { + func endpointProfileFromVerifiedSelectionIsTokenless() throws { let fixture = try Fixture() let policy = try CmxIrohRelayPolicyVerifier().verify( fixture.token(sequence: 7), @@ -310,25 +401,11 @@ struct CmxIrohRelayPolicyTests { policy: policy, selection: .only(["cmux-eu"]) ) - let selected = try fixture.relayConfiguration(url: fixture.relayURLs[1]) - let profile = try CmxIrohEndpointRelayProfile( - snapshot: snapshot, - relays: [selected] - ) + let profile = try CmxIrohEndpointRelayProfile(snapshot: snapshot) #expect(profile.allowedRelayURLs == [fixture.relayURLs[1]]) - #expect(profile.managedRelays == [selected]) - #expect(throws: CmxIrohEndpointConfigurationError.incompleteManagedRelayCredentials) { - try CmxIrohEndpointRelayProfile(snapshot: snapshot, relays: []) - } - let substituted = try fixture.relayConfiguration( - url: "https://capture.example.com/" - ) - #expect( - throws: CmxIrohEndpointConfigurationError.unmanagedRelayURL(substituted.url) - ) { - try CmxIrohEndpointRelayProfile(snapshot: snapshot, relays: [substituted]) - } + #expect(profile.activeRelays.map(\.url) == [fixture.relayURLs[1]]) + #expect(profile.activeRelays.allSatisfy { $0.authenticationToken == nil }) } private struct Fixture { @@ -425,15 +502,5 @@ struct CmxIrohRelayPolicyTests { .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } - - func relayConfiguration(url: String) throws -> CmxIrohRelayConfiguration { - try CmxIrohRelayConfiguration( - url: url, - token: "aaaa", - expiresAt: now.addingTimeInterval(3_600), - refreshAfter: now.addingTimeInterval(1_800), - now: now - ) - } } } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift index 5d863b360390..93c14883014d 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift @@ -51,8 +51,7 @@ struct CmxIrohSelectedTransportPathTests { relays: [descriptor] ) let endpointProfile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: [url], - relays: [] + managedRelayURLs: [url] ) let effective = CmxIrohEffectiveRelayPolicy( endpointRelayProfile: endpointProfile, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift index 8cd6e833f7d2..ed268f43971a 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift @@ -49,6 +49,81 @@ struct CmxIrohTrustBrokerClientTests { #expect(object["identityGeneration"] as? Int == 1) } + /// The DEBUG-only deployment-protection bypass rides every broker + /// request so a tagged test build can reach a protected preview. + @Test + func brokerRequestsCarryDebugProtectionBypassHeaderWhenActive() async throws { + UserDefaults.standard.set( + "test-bypass-token", + forKey: CmxIrohDebugBrokerBypassHeader.key + ) + defer { + UserDefaults.standard.removeObject( + forKey: CmxIrohDebugBrokerBypassHeader.key + ) + } + let transport = RecordingBrokerTransport(responses: [ + .json( + status: 201, + body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# + ), + ]) + let client = try makeClient(transport: transport) + let payload = try registrationPayload() + let signer = try registrationSigner() + _ = try await client.issueChallenge( + try signer.prepare(payload: payload).challengeRequest + ) + + let captured = try #require(await transport.requests().first) + #expect( + captured.value(forHTTPHeaderField: "x-vercel-protection-bypass") + == "test-bypass-token" + ) + } + + @Test + func brokerRequestsOmitProtectionBypassHeaderWhenInactive() async throws { + let transport = RecordingBrokerTransport(responses: [ + .json( + status: 201, + body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# + ), + ]) + let client = try makeClient(transport: transport) + let payload = try registrationPayload() + let signer = try registrationSigner() + _ = try await client.issueChallenge( + try signer.prepare(payload: payload).challengeRequest + ) + + let captured = try #require(await transport.requests().first) + #expect( + captured.value(forHTTPHeaderField: "x-vercel-protection-bypass") == nil + ) + } + + /// Only bounded single-line tokens are usable as a bypass header value. + @Test(arguments: [ + nil, + "", + " ", + "two words", + "line\nbreak", + String(repeating: "a", count: 129), + ] as [String?]) + func bypassRejectsUnusableValues(_ raw: String?) { + #expect(CmxIrohDebugBrokerBypassHeader.value(rawValue: raw) == nil) + } + + @Test + func bypassTrimsAndAcceptsBoundedToken() { + #expect( + CmxIrohDebugBrokerBypassHeader.value(rawValue: " V4wToken123 ") + == "V4wToken123" + ) + } + @Test func combinedRegistrationUsesOneGateForBothHTTPLegs() async throws { let transport = RecordingBrokerTransport(responses: [ @@ -284,8 +359,11 @@ struct CmxIrohTrustBrokerClientTests { #expect(!response.embeddedDiscoveryComplete) } + /// A legacy "issued" relay status decodes without retaining any token: + /// clients hold no relay credentials, so the payload is dropped on the + /// floor and the status collapses to the credential-free case. @Test - func issuedRegistrationBuildsTheExactManagedRelayFleet() async throws { + func issuedRegistrationRelayStatusDecodesWithoutRetainingTokens() async throws { let transport = RecordingBrokerTransport(responses: [ .json(status: 201, body: Self.registrationResponse), ]) @@ -298,14 +376,7 @@ struct CmxIrohTrustBrokerClientTests { signature: String(repeating: "A", count: 86) ) ) - guard case let .issued(relay) = response.relay else { - Issue.record("Expected an issued relay credential") - return - } - let now = try #require(ISO8601DateFormatter().date(from: "2026-07-10T00:00:00Z")) - let configurations = try relay.relayConfigurations(now: now) - #expect(configurations.map(\.url) == Self.relayURLs) - #expect(configurations.allSatisfy { $0.token == "abc234" }) + #expect(response.relay == .unavailable) } @Test @@ -336,197 +407,6 @@ struct CmxIrohTrustBrokerClientTests { #expect(response.relay == .notRequested) } - @Test - func relayTokenBindsCanonicalHexEndpointAndNormalizesFleetOrigins() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(Self.relayJWT)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://usc1.relay.cmux.dev","https://euw4.relay.cmux.dev/"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - let response = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - - #expect(response.relayFleet == [ - "https://usc1.relay.cmux.dev/", - "https://euw4.relay.cmux.dev/", - ]) - let configurations = try response.relayConfigurations( - now: Date(timeIntervalSince1970: 1_782_000_000) - ) - #expect(configurations.count == 2) - #expect(configurations.allSatisfy { - $0.token == Self.relayJWT - }) - - let captured = try #require(await transport.requests().first) - #expect(captured.url?.path == "/api/relay/token") - let body = try #require(captured.httpBody) - let object = try #require( - JSONSerialization.jsonObject(with: body) as? [String: Any] - ) - #expect(object.count == 1) - #expect(object["endpointId"] as? String == Self.endpointID) - } - - @Test - func relayTokenPreservesDistinctCredentialsForEachServerDrivenRelay() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - { - "endpointId":"\(Self.endpointID)", - "relayCredentials":[ - { - "relayUrl":"https://usc1.relay.cmux.dev", - "token":"abc234", - "expiresAt":1782000300, - "refreshAfter":1782000240, - "ttlSeconds":300 - }, - { - "relayUrl":"https://relay.other.example/", - "token":"def567", - "expiresAt":1782000360, - "refreshAfter":1782000240, - "ttlSeconds":360 - } - ] - } - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - let response = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - - #expect(response.relayFleet == [ - "https://usc1.relay.cmux.dev/", - "https://relay.other.example/", - ]) - let configurations = try response.relayConfigurations( - now: Date(timeIntervalSince1970: 1_782_000_000) - ) - #expect(configurations.map(\.token) == ["abc234", "def567"]) - #expect(configurations[0].expiresAt != configurations[1].expiresAt) - - let captured = try #require(await transport.requests().first) - #expect(captured.url?.path == "/api/relay/token") - } - - @Test - func relayTokenRejectsCredentialAssociationForAnotherEndpoint() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - { - "endpointId":"\(String(repeating: "f", count: 64))", - "relayCredentials":[{ - "relayUrl":"https://usc1.relay.cmux.dev/", - "token":"abc234", - "expiresAt":1782000300, - "refreshAfter":1782000240, - "ttlSeconds":300 - }] - } - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsCredentialCatalogAboveBound() async throws { - let credentials = (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount + 1) - .map { index in - """ - {"relayUrl":"https://relay-\(index).example/","token":"abc234","expiresAt":1782000300,"refreshAfter":1782000240,"ttlSeconds":300} - """ - } - .joined(separator: ",") - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"endpointId":"\(Self.endpointID)","relayCredentials":[\(credentials)]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsNonOriginFleetURL() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(Self.relayJWT)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://relay.cmux.dev/capture"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsJWTBoundToAnotherEndpoint() async throws { - let substituted = Self.makeRelayJWT(endpointID: String(repeating: "f", count: 64)) - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(substituted)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://usc1.relay.cmux.dev"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - @Test func revokeUsesTheBrokerDeleteRoute() async throws { let transport = RecordingBrokerTransport(responses: [ diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift index d38a5ba43e8f..9ae0d9a086fe 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift @@ -68,17 +68,6 @@ struct RelayPolicyServiceTestFixture { return "\(input).\(Self.base64URL(signature))" } - func relayCredential() -> CmxIrohRelayTokenResponse { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return CmxIrohRelayTokenResponse( - token: "aaaa", - expiresAt: formatter.string(from: now.addingTimeInterval(3_600)), - refreshAfter: formatter.string(from: now.addingTimeInterval(1_800)), - relayFleet: relayURLs - ) - } - private func trustRoot( includeFirst: Bool, includeSecond: Bool diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift index 69b72c0fddbc..bc89722ccd5b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift @@ -35,11 +35,11 @@ actor TestBlockingRelayUpdateEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) async { + func replaceRelayProfile(_: CmxIrohEndpointRelayProfile) async { updateContinuation.yield(()) await withCheckedContinuation { continuation in releaseContinuation = continuation diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift index 28535077a35c..1e19f62d3471 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestCancellableDialEndpoint.swift @@ -44,11 +44,10 @@ actor TestCancellableDialEndpoint: CmxIrohEndpoint { }) } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift index bba6540dfd6f..e4403a830dd8 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift @@ -50,11 +50,10 @@ actor TestDialingIrohEndpoint: CmxIrohEndpoint { } } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { healthStream diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift index e39fee9f38a6..5fd4c1e2b799 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestGatedDialEndpoint.swift @@ -32,11 +32,10 @@ actor TestGatedDialEndpoint: CmxIrohEndpoint { } } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift index 1af9fb607849..77cd76cb381b 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift @@ -43,11 +43,10 @@ actor TestHangingDialEndpoint: CmxIrohEndpoint { }) } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift index 49705c8f5c4e..270eb18e63a2 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift @@ -5,7 +5,6 @@ import Foundation actor TestIrohClientBroker: CmxIrohClientBrokerServing { private let registration: CmxIrohRegistrationResponse private let discoveryResponse: CmxIrohDiscoveryResponse - private let relayResponse: CmxIrohRelayTokenResponse private let pairGrantResponse: CmxIrohPairGrantResponse? private let bindingAuthorizationAvailable: Bool private let revokeError: (any Error)? @@ -15,7 +14,6 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { private var registrationErrorsByCount: [Int: any Error] = [:] private var preparedRegistrations: [CmxIrohPreparedRegistration] = [] private var revokedBindingIDs: [String] = [] - private var relayIssueCount = 0 private var discoveryCount = 0 private var discoveryErrorsByCount: [Int: any Error] = [:] private var registrationCountWaiters: [ @@ -28,10 +26,8 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { init( binding: CmxIrohBrokerBinding, discovery: CmxIrohDiscoveryResponse, - relay: CmxIrohRelayTokenResponse, pairGrant: CmxIrohPairGrantResponse? = nil, bindingAuthorizationAvailable: Bool = true, - issueRelayAtRegistration: Bool = true, registrationError: (any Error)? = nil, discoveryErrorsByCount: [Int: any Error] = [:], revokeError: (any Error)? = nil, @@ -40,10 +36,9 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { ) { registration = CmxIrohRegistrationResponse( binding: binding, - relay: issueRelayAtRegistration ? .issued(relay) : .unavailable + relay: .unavailable ) discoveryResponse = discovery - relayResponse = relay pairGrantResponse = pairGrant self.bindingAuthorizationAvailable = bindingAuthorizationAvailable self.revokeError = revokeError @@ -107,14 +102,6 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { return pairGrantResponse } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) -> CmxIrohRelayTokenResponse { - relayIssueCount += 1 - return relayResponse - } - func revoke(bindingID: String) throws { revokedBindingIDs.append(bindingID) if let revokeError { throw revokeError } @@ -136,10 +123,6 @@ actor TestIrohClientBroker: CmxIrohClientBrokerServing { revokedBindingIDs } - func observedRelayIssueCount() -> Int { - relayIssueCount - } - func observedDiscoveryCount() -> Int { discoveryCount } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift index 76a118005180..407604155bd2 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift @@ -10,7 +10,6 @@ actor TestIrohEndpoint: CmxIrohEndpoint { private let healthStream: AsyncStream private let healthContinuation: AsyncStream.Continuation private var closeCallCount = 0 - private var relayUpdates: [[CmxIrohRelayConfiguration]] = [] private var relayProfileUpdates: [CmxIrohEndpointRelayProfile] = [] private var relayUpdateShouldFail = false private var healthy = true @@ -75,6 +74,10 @@ actor TestIrohEndpoint: CmxIrohEndpoint { func localDirectAddresses() -> [String] { directAddresses } + func setPathHints(_ hints: [CmxIrohPathHint]) { + pathHints = hints + } + func setDirectAddresses(_ addresses: [String]) { directAddresses = addresses } @@ -86,27 +89,20 @@ actor TestIrohEndpoint: CmxIrohEndpoint { throw TestIrohTransportError.unsupported } - func accept() async throws -> (any CmxIrohConnection)? { + func accept() async throws -> (any CmxIrohIncomingConnection)? { nil } - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) throws { + func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) throws { if relayUpdateShouldFail { throw TestIrohTransportError.relayUpdateFailed } - relayUpdates.append(relays) + relayProfileUpdates.append(profile) if let pathHintsAfterRelayReplacement { pathHints = pathHintsAfterRelayReplacement } } - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) throws { - if relayUpdateShouldFail { - throw TestIrohTransportError.relayUpdateFailed - } - relayProfileUpdates.append(profile) - } - func healthEvents() -> AsyncStream { healthStream } @@ -136,10 +132,6 @@ actor TestIrohEndpoint: CmxIrohEndpoint { closeCallCount } - func observedRelayUpdates() -> [[CmxIrohRelayConfiguration]] { - relayUpdates - } - func observedRelayProfileUpdates() -> [CmxIrohEndpointRelayProfile] { relayProfileUpdates } diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestUncancellableIrohReceiveStream.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestUncancellableIrohReceiveStream.swift new file mode 100644 index 000000000000..0bb96637f59a --- /dev/null +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestUncancellableIrohReceiveStream.swift @@ -0,0 +1,32 @@ +import Foundation +@testable import CmuxIrohTransport + +/// A control stream whose pending read ignores Swift task cancellation +/// entirely, mirroring the FFI driver contract: the generated bindings +/// suspend the caller on a polled Rust future that `Task.cancel()` never +/// touches. Only a transport-boundary abort (`failPendingReceives`, wired +/// to connection close in tests) terminates the read. +actor TestUncancellableIrohReceiveStream: CmxIrohReceiveStream { + private var pendingReceives: [CheckedContinuation] = [] + private var failed = false + + func receive(maximumByteCount _: Int) async throws -> Data? { + guard !failed else { throw TestIrohTransportError.unsupported } + return try await withCheckedThrowingContinuation { continuation in + pendingReceives.append(continuation) + } + } + + func stop(errorCode _: UInt64) {} + + /// Models the QUIC semantics of closing the owning connection: every + /// pending and future stream read fails immediately. + func failPendingReceives() { + failed = true + let pending = pendingReceives + pendingReceives = [] + for continuation in pending { + continuation.resume(throwing: TestIrohTransportError.unsupported) + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift deleted file mode 100644 index 614769d7c4c1..000000000000 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift +++ /dev/null @@ -1,30 +0,0 @@ -#if DEBUG -import Foundation - -struct MobileIrohReleaseGateArtifactPreparation: Equatable, Sendable { - static let requiredStableStatObservations = 2 - - let path: String - let suffixText: String - let completionMarker: String - let command: String - - static func make( - path: String, - suffixText: String, - marker: String - ) -> MobileIrohReleaseGateArtifactPreparation { - let completionPrefix = "CMUX_IROH_ARTIFACT_READY_" - let completionNonce = String(marker.suffix(24)) - return MobileIrohReleaseGateArtifactPreparation( - path: path, - suffixText: suffixText, - completionMarker: completionPrefix + completionNonce, - command: "dd if=/dev/zero of='\(path)' bs=1048576 count=32 2>/dev/null; " - + "printf '%s' '\(suffixText)' >> '\(path)'; " - + "printf '\\n%s\\n' '\(path)'; " - + "printf '\\n%s%s\\n' '\(completionPrefix)' '\(completionNonce)'\n" - ) - } -} -#endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift index b110b6e03cd8..d607899e183e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift @@ -39,39 +39,5 @@ public enum MobileIrohReleaseGateProbeFailure: String, Error, Equatable, Sendabl case chatSessionsFailed /// The terminal artifact count-only scan failed validation. case artifactScanCountFailed - /// Required local endpoint or QUIC continuity evidence was unavailable. - case continuityEvidenceUnavailable - /// The endpoint, native connection, or credential expiry violated rollover. - case relayRolloverFailed - /// The RPC control stream or held terminal stream did not survive rollover. - case controlStreamContinuityFailed - /// The installed independent event registration did not survive rollover. - case independentEventsContinuityFailed - /// The terminal never confirmed that the rollover artifact was closed. - case artifactCommandNotCompleted - /// The artifact scan never authorized the exact generated path. - case artifactScanPathMissing - /// The artifact did not reach two stable observations at the expected size. - case artifactStatSizeMismatch - /// Artifact scan or stat RPC transport failed before readiness was established. - case artifactReadinessRPCFailed - /// The Mac returned no valid artifact-lane descriptor for the completed file. - case artifactDescriptorInvalid - /// The artifact descriptor RPC failed before returning a response. - case artifactDescriptorRPCFailed - /// The independent Iroh artifact stream could not be opened. - case artifactLaneOpenFailed - /// The independent Iroh artifact stream returned a transport read error. - case artifactLaneReadFailed - /// The independent artifact stream did not begin with the expected byte. - case artifactLaneInitialByteMismatch - /// The independent artifact stream returned more bytes than the descriptor promised. - case artifactLaneOverrun - /// The independent artifact stream ended before the descriptor's promised size. - case artifactLaneTruncated - /// The independent artifact stream ended with unexpected content. - case artifactLaneTailMismatch - /// A held relay credential remained admitted beyond its hard expiry. - case unrefreshedCredentialDidNotDisconnect } #endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift index 80bdd5a8df45..9b38438fbdbc 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift @@ -17,32 +17,8 @@ public struct MobileIrohReleaseGateProbeResult: Equatable, Sendable { public let chatSessionsVerified: Bool /// Whether a content-free terminal artifact count scan decoded. public let artifactScanCountVerified: Bool - /// Whether the installed relay credential advanced past its old expiry. - public let relayCredentialRolloverVerified: Bool - /// Whether the local EndpointID stayed unchanged through rollover. - public let endpointContinuityVerified: Bool - /// Whether the exact native QUIC connection stayed unchanged. - public let connectionContinuityVerified: Bool - /// Whether the same RPC control stream remained usable through rollover. - public let controlStreamContinuityVerified: Bool - /// Whether one independent event registration remained installed. - public let independentEventsContinuityVerified: Bool - /// Whether an already-open artifact lane delivered after old expiry. - public let artifactLaneVerified: Bool - /// Whether a deliberately unrefreshed relay credential caused disconnect. - public let unrefreshedExpiryDisconnectVerified: Bool - /// Whole seconds spent driving control traffic during rollover. - public let soakDurationSeconds: Int /// Creates a successful probe result. - /// - Parameters: - /// - hostStatusVerified: Host-status verification result. - /// - terminalRoundTripVerified: Terminal round-trip verification result. - /// - workspaceMutationVerified: Reversible workspace mutation result. - /// - independentEventsVerified: Independent event lane verification result. - /// - notificationReconcileVerified: Notification reconcile verification result. - /// - chatSessionsVerified: Chat-session snapshot verification result. - /// - artifactScanCountVerified: Artifact count-only scan verification result. public init( hostStatusVerified: Bool, rpcMethodInventoryVerified: Bool, @@ -51,15 +27,7 @@ public struct MobileIrohReleaseGateProbeResult: Equatable, Sendable { independentEventsVerified: Bool, notificationReconcileVerified: Bool, chatSessionsVerified: Bool, - artifactScanCountVerified: Bool, - relayCredentialRolloverVerified: Bool = false, - endpointContinuityVerified: Bool = false, - connectionContinuityVerified: Bool = false, - controlStreamContinuityVerified: Bool = false, - independentEventsContinuityVerified: Bool = false, - artifactLaneVerified: Bool = false, - unrefreshedExpiryDisconnectVerified: Bool = false, - soakDurationSeconds: Int = 0 + artifactScanCountVerified: Bool ) { self.hostStatusVerified = hostStatusVerified self.rpcMethodInventoryVerified = rpcMethodInventoryVerified @@ -69,14 +37,6 @@ public struct MobileIrohReleaseGateProbeResult: Equatable, Sendable { self.notificationReconcileVerified = notificationReconcileVerified self.chatSessionsVerified = chatSessionsVerified self.artifactScanCountVerified = artifactScanCountVerified - self.relayCredentialRolloverVerified = relayCredentialRolloverVerified - self.endpointContinuityVerified = endpointContinuityVerified - self.connectionContinuityVerified = connectionContinuityVerified - self.controlStreamContinuityVerified = controlStreamContinuityVerified - self.independentEventsContinuityVerified = independentEventsContinuityVerified - self.artifactLaneVerified = artifactLaneVerified - self.unrefreshedExpiryDisconnectVerified = unrefreshedExpiryDisconnectVerified - self.soakDurationSeconds = soakDurationSeconds } } #endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift index fce93f23fc01..b5b577904832 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift @@ -89,43 +89,5 @@ enum MobileIrohReleaseGateResponseValidator { return response.sessionArtifactTotal.map { $0 >= 0 } ?? true } - static func artifactPath( - _ data: Data, - expectedPath: String - ) -> Bool { - guard let response = try? ChatWireCoding().decode( - TerminalArtifactScanResponse.self, - from: data - ) else { - return false - } - let expectedIdentity = releaseGateArtifactPathIdentity(expectedPath) - return response.artifacts.contains { - releaseGateArtifactPathIdentity($0.path) == expectedIdentity - } - } - - private static func releaseGateArtifactPathIdentity(_ path: String) -> String { - let standardized = (path as NSString).standardizingPath - let canonicalMacOSTemporaryPrefix = "/private/tmp/" - guard standardized.hasPrefix(canonicalMacOSTemporaryPrefix) else { - return standardized - } - return "/tmp/" + standardized.dropFirst(canonicalMacOSTemporaryPrefix.count) - } - - static func artifactLaneDescriptor(_ data: Data) -> ChatArtifactLaneDescriptor? { - try? ChatWireCoding().decode(ChatArtifactLaneDescriptor.self, from: data) - } - - static func artifactStat( - _ data: Data, - expectedSize: Int64 - ) -> Bool { - guard let stat = try? ChatWireCoding().decode(ChatArtifactStat.self, from: data) else { - return false - } - return stat.exists && !stat.isDirectory && stat.size == expectedSize - } } #endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift deleted file mode 100644 index 7cdca304823e..000000000000 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift +++ /dev/null @@ -1,11 +0,0 @@ -#if DEBUG -/// Long-running relay credential behavior selected by the isolated release gate. -public enum MobileIrohReleaseGateScenario: String, Equatable, Sendable { - /// Existing short app-RPC coverage. - case standard - /// Keep live application lanes open while the relay credential refreshes. - case relayRollover = "relay_rollover" - /// Hold the initial credential and require the relay to close at expiry. - case relayExpiry = "relay_expiry" -} -#endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift index 1e01c35d6a85..46c6a6ee5f74 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift @@ -24,11 +24,7 @@ extension MobileShellComposite { /// - Returns: Credential-free proof that all operations succeeded. /// - Throws: ``MobileIrohReleaseGateProbeFailure`` when an invariant fails. public func runIrohReleaseGateProbe( - marker: String, - scenario: MobileIrohReleaseGateScenario = .standard, - soakDurationSeconds: Int = 0, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity? = { nil }, - relayCredentialExpiry: @escaping @Sendable () async -> Date? = { nil } + marker: String ) async throws -> MobileIrohReleaseGateProbeResult { guard connectionState == .connected, activeRoute?.kind == .iroh, @@ -62,58 +58,19 @@ extension MobileShellComposite { } let workspace = target.workspace let terminalID = target.terminalID.rawValue - var relayCredentialRolloverVerified = false - var endpointContinuityVerified = false - var connectionContinuityVerified = false - var controlStreamContinuityVerified = false - var independentEventsContinuityVerified = false - var artifactLaneVerified = false - var unrefreshedExpiryDisconnectVerified = false - switch scenario { - case .standard: - try await verifyReversibleWorkspaceRename( - workspace: workspace, - marker: marker - ) - try await verifyTerminalRoundTrip( - surfaceID: terminalID, - marker: marker - ) - try await verifyIndependentEvents( - client: remoteClient, - marker: marker - ) - case .relayRollover: - let continuity = try await verifyRelayCredentialRollover( - client: remoteClient, - workspace: workspace, - surfaceID: terminalID, - marker: marker, - soakDurationSeconds: soakDurationSeconds, - endpointIdentity: endpointIdentity, - relayCredentialExpiry: relayCredentialExpiry - ) - relayCredentialRolloverVerified = continuity.relayCredentialRolloverVerified - endpointContinuityVerified = continuity.endpointContinuityVerified - connectionContinuityVerified = continuity.connectionContinuityVerified - controlStreamContinuityVerified = continuity.controlStreamContinuityVerified - independentEventsContinuityVerified = continuity.independentEventsContinuityVerified - artifactLaneVerified = continuity.artifactLaneVerified - case .relayExpiry: - try await verifyReversibleWorkspaceRename( - workspace: workspace, - marker: marker - ) - try await verifyTerminalRoundTrip( - surfaceID: terminalID, - marker: marker - ) - try await verifyIndependentEvents( - client: remoteClient, - marker: marker - ) - } + try await verifyReversibleWorkspaceRename( + workspace: workspace, + marker: marker + ) + try await verifyTerminalRoundTrip( + surfaceID: terminalID, + marker: marker + ) + try await verifyIndependentEvents( + client: remoteClient, + marker: marker + ) mobileIrohReleaseGateProbeLog.info("probe stage=workspace_mutation state=completed") mobileIrohReleaseGateProbeLog.info("probe stage=terminal_round_trip state=completed") mobileIrohReleaseGateProbeLog.info("probe stage=independent_events state=completed") @@ -134,14 +91,6 @@ extension MobileShellComposite { ) mobileIrohReleaseGateProbeLog.info("probe stage=artifact_scan_count state=completed") - if scenario == .relayExpiry { - unrefreshedExpiryDisconnectVerified = try await verifyUnrefreshedRelayExpiry( - client: remoteClient, - endpointIdentity: endpointIdentity, - relayCredentialExpiry: relayCredentialExpiry - ) - } - return MobileIrohReleaseGateProbeResult( hostStatusVerified: true, rpcMethodInventoryVerified: true, @@ -150,15 +99,7 @@ extension MobileShellComposite { independentEventsVerified: true, notificationReconcileVerified: true, chatSessionsVerified: true, - artifactScanCountVerified: true, - relayCredentialRolloverVerified: relayCredentialRolloverVerified, - endpointContinuityVerified: endpointContinuityVerified, - connectionContinuityVerified: connectionContinuityVerified, - controlStreamContinuityVerified: controlStreamContinuityVerified, - independentEventsContinuityVerified: independentEventsContinuityVerified, - artifactLaneVerified: artifactLaneVerified, - unrefreshedExpiryDisconnectVerified: unrefreshedExpiryDisconnectVerified, - soakDurationSeconds: scenario == .relayRollover ? soakDurationSeconds : 0 + artifactScanCountVerified: true ) } @@ -196,530 +137,6 @@ extension MobileShellComposite { } } - private struct RelayRolloverContinuity { - let relayCredentialRolloverVerified: Bool - let endpointContinuityVerified: Bool - let connectionContinuityVerified: Bool - let controlStreamContinuityVerified: Bool - let independentEventsContinuityVerified: Bool - let artifactLaneVerified: Bool - } - - private func verifyRelayCredentialRollover( - client: MobileCoreRPCClient, - workspace: MobileWorkspacePreview, - surfaceID: String, - marker: String, - soakDurationSeconds: Int, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity?, - relayCredentialExpiry: @escaping @Sendable () async -> Date? - ) async throws -> RelayRolloverContinuity { - guard soakDurationSeconds >= 330, - let endpointBefore = await endpointIdentity(), - let credentialExpiryBefore = await relayCredentialExpiry(), - let connectionBefore = await client.transportContinuityID() else { - throw MobileIrohReleaseGateProbeFailure.continuityEvidenceUnavailable - } - - let streamID = "iroh-release-gate-\(marker.suffix(32))" - let eventMarker = "cmux Iroh gate \(marker.suffix(8))" - let subscribe = try MobileCoreRPCClient.requestData( - method: "mobile.events.subscribe", - params: [ - "stream_id": streamID, - "topics": ["workspace.updated"], - ] - ) - let subscribeData = try await client.sendRequest(subscribe) - guard MobileIrohReleaseGateResponseValidator.independentEventSubscription( - subscribeData, - expectedStreamID: streamID, - expectedAlreadySubscribed: false - ) else { - throw MobileIrohReleaseGateProbeFailure.independentEventsContinuityFailed - } - - let artifactPath = "/tmp/cmux-iroh-gate-\(marker.suffix(24)).bin" - // noq's pinned default per-stream receive window is 1.25 MB. Keeping a - // 32 MB prefix unread guarantees the sender remains flow-controlled, - // rather than letting a fully buffered payload masquerade as a live - // post-rollover artifact lane. - let artifactPrefixByteCount = 32 * 1_024 * 1_024 - let artifactSuffix = Data("CMUX_IROH_ARTIFACT_\(marker.suffix(24))".utf8) - let artifactTotalByteCount = artifactPrefixByteCount + artifactSuffix.count - let artifactSuffixText = String(decoding: artifactSuffix, as: UTF8.self) - let artifactPreparation = MobileIrohReleaseGateArtifactPreparation.make( - path: artifactPath, - suffixText: artifactSuffixText, - marker: marker - ) - mobileIrohReleaseGateProbeLog.info("probe stage=artifact_prepare state=begin") - await submitTerminalRawInput( - Data(artifactPreparation.command.utf8), - surfaceID: surfaceID - ) - - mobileIrohReleaseGateProbeLog.info("probe stage=artifact_readiness state=begin") - let readiness: ArtifactReadiness - do { - readiness = try await waitForArtifact( - client: client, - workspaceID: workspace.rpcWorkspaceID.rawValue, - surfaceID: surfaceID, - path: artifactPath, - expectedSize: Int64(artifactTotalByteCount) - ) - } catch { - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_readiness state=failed reason=rpc" - ) - throw MobileIrohReleaseGateProbeFailure.artifactReadinessRPCFailed - } - switch readiness { - case .ready: - mobileIrohReleaseGateProbeLog.info( - "probe stage=artifact_readiness state=completed" - ) - case .scanPathMissing: - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_readiness state=failed reason=scan_path_missing" - ) - throw MobileIrohReleaseGateProbeFailure.artifactScanPathMissing - case .statSizeMismatch: - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_readiness state=failed reason=stat_size_mismatch" - ) - throw MobileIrohReleaseGateProbeFailure.artifactStatSizeMismatch - } - mobileIrohReleaseGateProbeLog.info("probe stage=artifact_prepare state=completed") - let descriptorData: Data - do { - let descriptorRequest = try MobileCoreRPCClient.requestData( - method: "mobile.terminal.artifact.fetch", - params: [ - "workspace_id": workspace.rpcWorkspaceID.rawValue, - "surface_id": surfaceID, - "path": artifactPath, - "transport": "iroh_artifact_v1", - ] - ) - descriptorData = try await client.sendRequest(descriptorRequest) - } catch { - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_descriptor state=failed reason=rpc" - ) - throw MobileIrohReleaseGateProbeFailure.artifactDescriptorRPCFailed - } - guard let descriptor = MobileIrohReleaseGateResponseValidator.artifactLaneDescriptor( - descriptorData - ), descriptor.totalSize == Int64(artifactTotalByteCount) else { - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_descriptor state=failed" - ) - throw MobileIrohReleaseGateProbeFailure.artifactDescriptorInvalid - } - let artifactConnection: any MobileArtifactLaneConnection - do { - artifactConnection = try await client.openArtifactLane( - resourceID: descriptor.resourceID, - offset: 0 - ) - } catch { - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_lane_open state=failed" - ) - throw MobileIrohReleaseGateProbeFailure.artifactLaneOpenFailed - } - let initialArtifactByte: Data? - do { - initialArtifactByte = try await artifactConnection.receive(maximumByteCount: 1) - } catch { - await artifactConnection.close() - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_lane_first_byte state=failed reason=read" - ) - throw MobileIrohReleaseGateProbeFailure.artifactLaneReadFailed - } - guard initialArtifactByte == Data([0]) else { - await artifactConnection.close() - await cleanUpRelayRolloverPreparation( - client: client, - streamID: streamID, - artifactPath: artifactPath, - surfaceID: surfaceID - ) - mobileIrohReleaseGateProbeLog.error( - "probe stage=artifact_lane_first_byte state=failed" - ) - throw MobileIrohReleaseGateProbeFailure.artifactLaneInitialByteMismatch - } - - do { - var remaining = soakDurationSeconds - while remaining > 0 { - let interval = min(15, remaining) - try await Task.sleep(for: .seconds(interval)) - let heartbeat = try MobileCoreRPCClient.requestData( - method: "workspace.list", - params: [:] - ) - _ = try await client.sendRequest(heartbeat) - remaining -= interval - } - - guard let endpointAfter = await endpointIdentity(), - let credentialExpiryAfter = await relayCredentialExpiry(), - let connectionAfter = await client.transportContinuityID(), - endpointAfter == endpointBefore, - connectionAfter == connectionBefore, - credentialExpiryAfter > credentialExpiryBefore else { - throw MobileIrohReleaseGateProbeFailure.relayRolloverFailed - } - - let postMarker = "\(marker)_POST_ROLLOVER" - let postMarkerProbe = MobileIrohReleaseGateRenderGridProbe( - surfaceID: surfaceID, - marker: postMarker - ) - var postMarkerIterator = await client.subscribe( - to: ["terminal.render_grid"] - ).makeAsyncIterator() - let postTerminalProbe = MobileIrohReleaseGateTerminalProbe( - marker: postMarker - ) - await submitTerminalRawInput( - postTerminalProbe.command, - surfaceID: surfaceID - ) - var sawPostMarker = false - while let event = await postMarkerIterator.next() { - if postMarkerProbe.consume(event) { - sawPostMarker = true - break - } - } - guard sawPostMarker else { - throw MobileIrohReleaseGateProbeFailure.controlStreamContinuityFailed - } - - let reassertData = try await client.sendRequest(subscribe) - guard MobileIrohReleaseGateResponseValidator.independentEventSubscription( - reassertData, - expectedStreamID: streamID, - expectedAlreadySubscribed: true - ) else { - throw MobileIrohReleaseGateProbeFailure.independentEventsContinuityFailed - } - try await verifyFreshWorkspaceEvent( - client: client, - workspace: workspace, - temporaryName: eventMarker - ) - try await restoreWorkspace(workspace) - - var receivedArtifactByteCount = 1 - var receivedArtifactTail = Data() - do { - while let chunk = try await artifactConnection.receive( - maximumByteCount: 64 * 1_024 - ) { - receivedArtifactByteCount += chunk.count - guard receivedArtifactByteCount <= artifactTotalByteCount else { - throw MobileIrohReleaseGateProbeFailure.artifactLaneOverrun - } - receivedArtifactTail.append(chunk) - if receivedArtifactTail.count > artifactSuffix.count { - receivedArtifactTail.removeFirst( - receivedArtifactTail.count - artifactSuffix.count - ) - } - } - } catch let failure as MobileIrohReleaseGateProbeFailure { - throw failure - } catch { - throw MobileIrohReleaseGateProbeFailure.artifactLaneReadFailed - } - guard receivedArtifactByteCount == artifactTotalByteCount else { - throw MobileIrohReleaseGateProbeFailure.artifactLaneTruncated - } - guard receivedArtifactTail == artifactSuffix else { - throw MobileIrohReleaseGateProbeFailure.artifactLaneTailMismatch - } - - await artifactConnection.close() - await bestEffortEventUnsubscribe(client: client, streamID: streamID) - await submitTerminalRawInput( - Data("rm -f '\(artifactPath)'\n".utf8), - surfaceID: surfaceID - ) - return RelayRolloverContinuity( - relayCredentialRolloverVerified: true, - endpointContinuityVerified: true, - connectionContinuityVerified: true, - controlStreamContinuityVerified: true, - independentEventsContinuityVerified: true, - artifactLaneVerified: true - ) - } catch { - await artifactConnection.close() - await bestEffortEventUnsubscribe(client: client, streamID: streamID) - await restoreWorkspaceBestEffort(workspace) - await submitTerminalRawInput( - Data("rm -f '\(artifactPath)'\n".utf8), - surfaceID: surfaceID - ) - if let failure = error as? MobileIrohReleaseGateProbeFailure { - throw failure - } - throw MobileIrohReleaseGateProbeFailure.relayRolloverFailed - } - } - - private func verifyUnrefreshedRelayExpiry( - client: MobileCoreRPCClient, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity?, - relayCredentialExpiry: @escaping @Sendable () async -> Date? - ) async throws -> Bool { - guard let endpointBefore = await endpointIdentity(), - let credentialExpiryBefore = await relayCredentialExpiry(), - await client.transportContinuityID() != nil, - let closureObservation = await client.transportClosureObservation() else { - throw MobileIrohReleaseGateProbeFailure.continuityEvidenceUnavailable - } - let deadline = credentialExpiryBefore.addingTimeInterval(20) - while Date() < deadline { - try await Task.sleep(for: .seconds(5)) - do { - let heartbeat = try MobileCoreRPCClient.requestData( - method: "workspace.list", - params: [:] - ) - _ = try await client.sendRequest(heartbeat) - } catch let error as MobileShellConnectionError { - guard Date() >= credentialExpiryBefore.addingTimeInterval(-2), - case .connectionClosed = error, - await endpointIdentity() == endpointBefore, - await relayCredentialExpiry() == credentialExpiryBefore, - await transportDidClose( - observation: closureObservation, - client: client - ) else { - throw MobileIrohReleaseGateProbeFailure.unrefreshedCredentialDidNotDisconnect - } - return true - } catch { - throw MobileIrohReleaseGateProbeFailure.unrefreshedCredentialDidNotDisconnect - } - } - throw MobileIrohReleaseGateProbeFailure.unrefreshedCredentialDidNotDisconnect - } - - private func verifyFreshWorkspaceEvent( - client: MobileCoreRPCClient, - workspace: MobileWorkspacePreview, - temporaryName: String - ) async throws { - let eventStream = await client.subscribe(to: ["workspace.updated"]) - try await withThrowingTaskGroup(of: Bool.self) { group in - group.addTask { - for await event in eventStream { - try Task.checkCancellation() - if Self.isFreshWorkspaceEvent(event) { - return true - } - } - return false - } - // Give the structured listener a scheduling opportunity before the - // mutation. A new local stream cannot contain pre-rollover events. - await Task.yield() - try await renameWorkspaceForEvent( - workspace: workspace, - temporaryName: temporaryName - ) - group.addTask { - try await Task.sleep(for: .seconds(10)) - throw MobileIrohReleaseGateProbeFailure.independentEventsContinuityFailed - } - defer { group.cancelAll() } - guard try await group.next() == true else { - throw MobileIrohReleaseGateProbeFailure.independentEventsContinuityFailed - } - } - } - - nonisolated private static func isFreshWorkspaceEvent( - _ event: MobileEventEnvelope - ) -> Bool { - // Host events are encoded once per connection and intentionally omit a - // subscription stream ID. The exact server registration is verified by - // the idempotent subscribe acknowledgement immediately before the - // controlled workspace mutation. - event.topic == "workspace.updated" - } - - private func transportDidClose( - observation: CmxTransportClosureObservation, - client: MobileCoreRPCClient - ) async -> Bool { - await observation.waitUntilClosed() - return await client.transportContinuityID() == nil - } - - private enum ArtifactReadiness { - case ready - case scanPathMissing - case statSizeMismatch - } - - private func waitForArtifact( - client: MobileCoreRPCClient, - workspaceID: String, - surfaceID: String, - path: String, - expectedSize: Int64 - ) async throws -> ArtifactReadiness { - let scanRequest = try MobileCoreRPCClient.requestData( - method: "mobile.terminal.artifact.scan", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - ] - ) - let statRequest = try MobileCoreRPCClient.requestData( - method: "mobile.terminal.artifact.stat", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - ] - ) - var sawExpectedPath = false - var consecutiveStableObservations = 0 - for _ in 0 ..< 100 { - let scanResponse = try await client.sendRequest(scanRequest) - if MobileIrohReleaseGateResponseValidator.artifactPath( - scanResponse, - expectedPath: path - ) { - sawExpectedPath = true - let statResponse = try await client.sendRequest(statRequest) - if MobileIrohReleaseGateResponseValidator.artifactStat( - statResponse, - expectedSize: expectedSize - ) { - consecutiveStableObservations += 1 - if consecutiveStableObservations - >= MobileIrohReleaseGateArtifactPreparation.requiredStableStatObservations { - return .ready - } - } else { - consecutiveStableObservations = 0 - } - } else { - consecutiveStableObservations = 0 - } - try await Task.sleep(for: .milliseconds(100)) - } - return sawExpectedPath ? .statSizeMismatch : .scanPathMissing - } - - private func cleanUpRelayRolloverPreparation( - client: MobileCoreRPCClient, - streamID: String, - artifactPath: String, - surfaceID: String - ) async { - await bestEffortEventUnsubscribe(client: client, streamID: streamID) - await submitTerminalRawInput( - Data("rm -f '\(artifactPath)'\n".utf8), - surfaceID: surfaceID - ) - } - - private func renameWorkspaceForEvent( - workspace: MobileWorkspacePreview, - temporaryName: String - ) async throws { - guard let currentWorkspace = irohReleaseGateCurrentWorkspace( - matching: workspace - ) else { - throw MobileIrohReleaseGateProbeFailure.workspaceMutationFailed - } - let result = await renameWorkspace( - id: currentWorkspace.id, - title: temporaryName - ) - guard case .success = result, - irohReleaseGateCurrentWorkspace(matching: workspace)?.name - == temporaryName else { - throw MobileIrohReleaseGateProbeFailure.workspaceMutationFailed - } - } - - private func restoreWorkspace(_ workspace: MobileWorkspacePreview) async throws { - guard let currentWorkspace = irohReleaseGateCurrentWorkspace( - matching: workspace - ) else { - throw MobileIrohReleaseGateProbeFailure.workspaceRestorationFailed - } - let result = await renameWorkspace( - id: currentWorkspace.id, - title: workspace.name - ) - guard case .success = result, - irohReleaseGateCurrentWorkspace(matching: workspace)?.name - == workspace.name else { - throw MobileIrohReleaseGateProbeFailure.workspaceRestorationFailed - } - } - - private func restoreWorkspaceBestEffort(_ workspace: MobileWorkspacePreview) async { - _ = try? await restoreWorkspace(workspace) - } - private func verifyIndependentEvents( client: MobileCoreRPCClient, marker: String diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift deleted file mode 100644 index 61b3355747a4..000000000000 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -#if DEBUG -import Testing -@testable import CmuxMobileShellReleaseGateSupport - -struct MobileIrohReleaseGateArtifactPreparationTests { - @Test - func completionMarkerCannotAppearInTheEchoedCommand() { - let preparation = MobileIrohReleaseGateArtifactPreparation.make( - path: "/tmp/cmux-iroh-gate-test.bin", - suffixText: "CMUX_IROH_ARTIFACT_TEST", - marker: "CMUX_IROH_GATE_TEST" - ) - - #expect(preparation.completionMarker.hasPrefix("CMUX_IROH_ARTIFACT_READY_")) - #expect(!preparation.command.contains(preparation.completionMarker)) - } - - @Test - func readinessRequiresTwoStableStatObservations() { - #expect(MobileIrohReleaseGateArtifactPreparation.requiredStableStatObservations == 2) - } - - @Test - func artifactPathIsPublishedOnItsOwnLineBeforeCompletion() { - let path = "/tmp/cmux-iroh-gate-test.bin" - let preparation = MobileIrohReleaseGateArtifactPreparation.make( - path: path, - suffixText: "CMUX_IROH_ARTIFACT_TEST", - marker: "CMUX_IROH_GATE_TEST" - ) - - let pathPublication = "printf '\\n%s\\n' '\(path)'" - let completionPublication = "printf '\\n%s%s\\n'" - let pathRange = preparation.command.range(of: pathPublication) - let completionRange = preparation.command.range(of: completionPublication) - - #expect(pathRange != nil) - #expect(completionRange != nil) - if let pathRange, let completionRange { - #expect(pathRange.upperBound < completionRange.lowerBound) - } - } -} -#endif diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift index 4af0e89f37e4..b0d665d878ea 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift @@ -112,80 +112,6 @@ struct MobileIrohReleaseGateResponseValidatorTests { )) } - @Test - func artifactContinuityRequiresTheExactAuthorizedPathAndLaneDescriptor() throws { - let path = "/tmp/cmux-iroh-gate.txt" - let scan = try ChatWireCoding().encode(TerminalArtifactScanResponse(artifacts: [ - TerminalArtifactReference( - path: path, - kind: .text, - displayName: "cmux-iroh-gate.txt", - size: 12 - ), - ])) - let descriptor = ChatArtifactLaneDescriptor( - resourceID: "opaque-resource", - totalSize: 12, - expiresAt: Date(timeIntervalSince1970: 2_000_000_000) - ) - let encodedDescriptor = try ChatWireCoding().encode(descriptor) - - #expect(MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: path - )) - #expect(!MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: "/tmp/other.txt" - )) - #expect( - MobileIrohReleaseGateResponseValidator.artifactLaneDescriptor(encodedDescriptor) - == descriptor - ) - - let stat = ChatArtifactStat( - exists: true, - isDirectory: false, - size: 12, - modifiedAt: Date(timeIntervalSince1970: 2_000_000_000), - kind: .text - ) - let encodedStat = try ChatWireCoding().encode(stat) - #expect(MobileIrohReleaseGateResponseValidator.artifactStat( - encodedStat, - expectedSize: 12 - )) - #expect(!MobileIrohReleaseGateResponseValidator.artifactStat( - encodedStat, - expectedSize: 13 - )) - } - - @Test - func artifactContinuityAcceptsTheCanonicalMacOSTemporaryDirectoryAlias() throws { - let scan = try ChatWireCoding().encode(TerminalArtifactScanResponse(artifacts: [ - TerminalArtifactReference( - path: "/private/tmp/cmux-iroh-gate-test.bin", - kind: .binary, - displayName: "cmux-iroh-gate-test.bin", - size: 12 - ), - ])) - - #expect(MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: "/tmp/cmux-iroh-gate-test.bin" - )) - #expect(!MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: "/tmp/cmux-iroh-gate-other.bin" - )) - #expect(!MobileIrohReleaseGateResponseValidator.artifactPath( - scan, - expectedPath: "/var/tmp/cmux-iroh-gate-test.bin" - )) - } - @Test func notificationReconcileRejectsNegativeUnreadCount() throws { let valid = try JSONSerialization.data(withJSONObject: [ diff --git a/Resources/Info.plist b/Resources/Info.plist index f8d344d1be44..8b9d04f1e9b2 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -268,6 +268,12 @@ publicKeyBase64 $(CMUX_IROH_RELAY_POLICY_NEXT_PUBLIC_KEY_BASE64) + + keyID + $(CMUX_IROH_RELAY_POLICY_EXTRA_KEY_ID) + publicKeyBase64 + $(CMUX_IROH_RELAY_POLICY_EXTRA_PUBLIC_KEY_BASE64) + diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f78fadb67384..14e85aa71134 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -13754,7 +13754,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hasDirtyWorkspaces: hasQuitConfirmationDirtyWorkspaces(), isDevBuild: BuildFlavor.current == .dev ) { - NSApp.terminate(nil) + AppTerminationRequest.schedule() return true } @@ -13767,7 +13767,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Mark as confirmed so applicationShouldTerminate does not show a // second alert when NSApp.terminate re-enters the delegate callback. self?.isQuitWarningConfirmed = true - NSApp.terminate(nil) + AppTerminationRequest.schedule() } else { onCancel?() } diff --git a/Sources/AppTerminationRequest.swift b/Sources/AppTerminationRequest.swift new file mode 100644 index 000000000000..5dc248819688 --- /dev/null +++ b/Sources/AppTerminationRequest.swift @@ -0,0 +1,32 @@ +import AppKit + +/// The shared terminate request used by the quit shortcut path (keyboard +/// Cmd+Q routing, socket-driven `simulate_shortcut`, and the quit +/// confirmation alert reply). +/// +/// `NSApp.terminate` must not run while the main dispatch queue is inside a +/// caller's block. When `applicationShouldTerminate` returns +/// `.terminateLater`, AppKit spins the run loop waiting for +/// `replyToApplicationShouldTerminate`, and the deferred `@MainActor` +/// cleanup task can only start once the main queue is free again. A debug +/// socket command executes inside `v2MainSync` (`DispatchQueue.main.sync`), +/// so terminating synchronously from it deadlocked the app +/// (https://github.com/manaflow-ai/cmux/issues/10788). Scheduling the +/// terminate as a main-run-loop callout lets the handler finish its reply +/// and release the main queue first, matching how a real keyboard Cmd+Q +/// arrives (a run-loop event callout with an idle main queue). +@MainActor +enum AppTerminationRequest { + /// Requests app termination from a later main-run-loop callout. + /// `terminate` is injectable for tests; production callers use the + /// default `NSApp.terminate`. + static func schedule( + _ terminate: @escaping @MainActor () -> Void = { NSApp.terminate(nil) } + ) { + RunLoop.main.perform(inModes: [.common]) { + MainActor.assumeIsolated { + terminate() + } + } + } +} diff --git a/Sources/ExtensionWorktreePrototype.swift b/Sources/ExtensionWorktreePrototype.swift index 457b8d9d12a6..486e0c59a850 100644 --- a/Sources/ExtensionWorktreePrototype.swift +++ b/Sources/ExtensionWorktreePrototype.swift @@ -12,8 +12,8 @@ struct CmuxExtensionWorktreeCreationResult: Sendable { let generatedArtifactContents: Data /// Filesystem identity captured immediately after `git worktree add`. /// Rollback refuses to touch a path whose checkout was replaced. - let worktreeDeviceID: UInt64? = nil - let worktreeFileID: UInt64? = nil + let worktreeDeviceID: UInt64? + let worktreeFileID: UInt64? /// A convenience command (e.g. a sample dev-server launcher) that should run /// inside the new workspace's interactive shell. This is *setup*, never the /// workspace's primary process. diff --git a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift index d8e1d46089a4..b14622aa595c 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift @@ -49,29 +49,10 @@ extension MobileHostIrohRuntime { && derivedEndpointID == $0.endpointID && $0.identityGeneration == identity.generation } ?? false - let cachedManagedRelayURLs: Set - if let relayPolicyTrustRoot, - let cachedPolicy = try? await relayPolicyCache.load( - trustRoot: relayPolicyTrustRoot, - now: Date() - ) { - cachedManagedRelayURLs = Set(cachedPolicy.relays.map(\.url)) - } else { - cachedManagedRelayURLs = [] - } - let cachedRelay: CmxIrohRelayTokenResponse? if let cachedBinding, bindingMatches { lastKnownBindingID = cachedBinding.bindingID lastKnownAccountID = accountID lastKnownTag = tag - cachedRelay = try await brokerCredentials.loadRelayCredential( - accountID: accountID, - binding: cachedBinding, - expectedRelayFleet: cachedManagedRelayURLs, - now: Date() - ) - } else { - cachedRelay = nil } let policyExpectation = try CmxIrohHostPolicyExpectation( accountID: accountID, @@ -176,7 +157,6 @@ extension MobileHostIrohRuntime { let managedRelayURLs: Set let resolvedPolicyService: CmxIrohRelayPolicyService? let resolvedEffectivePolicy: CmxIrohEffectiveRelayPolicy? - var freshRelayCredential: CmxIrohRelayTokenResponse? var relayPolicyNeedsImmediateRefresh = false if let relayPolicyTrustRoot { let service = CmxIrohRelayPolicyService( @@ -193,24 +173,19 @@ extension MobileHostIrohRuntime { effective = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, - relayCredential: cachedRelay, now: Date() ) relayPolicyNeedsImmediateRefresh = true } else { // Relay-only verification cannot become active without the - // current signed fleet and credential, so keep its explicit - // readiness barrier. + // current signed fleet, so keep its explicit readiness barrier. diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { - let outcome = try await service.refreshWithCredential( - endpointID: derivedEndpointID, + effective = try await service.refresh( accountID: accountID, trustRoot: relayPolicyTrustRoot, now: Date() ) - effective = outcome.effective - freshRelayCredential = outcome.relayCredential diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshSucceeded)) } catch { diagnosticLog.record(DiagnosticEvent( @@ -220,7 +195,6 @@ extension MobileHostIrohRuntime { effective = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, - relayCredential: cachedRelay, now: Date() ) relayPolicyNeedsImmediateRefresh = true @@ -246,12 +220,6 @@ extension MobileHostIrohRuntime { resolvedPolicyService = nil resolvedEffectivePolicy = nil } - let compatibleCachedRelay = cachedRelay.flatMap { relay in - Set(relay.relayFleet) == managedRelayURLs ? relay : nil - } - let freshCompatibleRelay = freshRelayCredential.flatMap { relay in - Set(relay.relayFleet) == managedRelayURLs ? relay : nil - } let configuration = CmxIrohHostRuntimeConfiguration( accountID: accountID, deviceID: deviceID, @@ -273,13 +241,11 @@ extension MobileHostIrohRuntime { ), managedRelayURLs: managedRelayURLs, endpointRelayProfile: endpointRelayProfile, - cachedRelayCredential: freshCompatibleRelay ?? compatibleCachedRelay, cachedHostPolicy: cachedHostPolicy ) let credentialRepository = brokerCredentials let hostPolicyCache = hostPolicies let lanPublisher = lanPublisher - let activeRelayPolicyService = resolvedPolicyService let hostRuntime = CmxIrohHostRuntime( factory: CmxIrohLibEndpointFactory( transportVerificationMode: transportVerificationMode @@ -458,21 +424,6 @@ extension MobileHostIrohRuntime { } ) }, - handleRelayCredential: { [weak self] response, binding in - guard await self?.allowsPersistence( - accountID: accountID, - revision: revision - ) == true else { return } - let expectedRelayFleet = await activeRelayPolicyService?.managedPolicy() - .map { Set($0.relays.map(\.url)) } ?? managedRelayURLs - try? await credentialRepository.saveRelayCredential( - response, - accountID: accountID, - binding: binding, - expectedRelayFleet: expectedRelayFleet, - now: Date() - ) - }, handleLANRefresh: { guard MobileHostService.isListeningEnabled else { await lanPublisher.stop() diff --git a/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift new file mode 100644 index 000000000000..d94023d25539 --- /dev/null +++ b/Sources/Mobile/MobileHostIrohRuntime+RelayDiag.swift @@ -0,0 +1,163 @@ +import CmuxIrohTransport +import Foundation +import os + +/// Relay lines for the `iroh_diag` socket verb. +/// +/// Relay URLs are deliberately kept out of `DiagnosticLog` and its report, +/// which stay privacy-safe for Settings exports; only the local debug socket +/// prints them, from the mirror below. +extension MobileHostIrohRuntime { + /// The relay policy fields the `iroh_diag` socket verb reports. + struct RelayDiagState: Equatable, Sendable { + let source: CmxIrohRelayPolicySource + let usedCachedPolicy: Bool + let relayURLs: [String] + /// Start of the current run of consecutive policy refresh failures, + /// mirrored from the service diagnostics so an unreachable-by-outage + /// host is visible in `iroh_diag` (cmux#10873). + let refreshFailingSince: Date? + let consecutiveRefreshFailures: Int + + init( + source: CmxIrohRelayPolicySource, + usedCachedPolicy: Bool, + relayURLs: [String], + refreshFailingSince: Date? = nil, + consecutiveRefreshFailures: Int = 0 + ) { + self.source = source + self.usedCachedPolicy = usedCachedPolicy + self.relayURLs = relayURLs + self.refreshFailingSince = refreshFailingSince + self.consecutiveRefreshFailures = consecutiveRefreshFailures + } + } + + /// The refresh failure streak alone, for the launches where no relay + /// policy was ever installed but the refresh loop is failing: the diag + /// must say why relays are absent instead of only "none installed". + struct RelayDiagRefreshFailure: Equatable, Sendable { + let since: Date + let consecutiveFailures: Int + } + + /// Mirror of the relay policy most recently installed by the account + /// pipeline. A lock rather than an actor, deliberately (matching the + /// `AgentChatThemeSync` precedent and the `hostDiagnosticLog` design): + /// the write must be visible synchronously when the + /// `relayPolicyEffective` didSet returns (an actor write would be a + /// detached hop, letting a concurrent `iroh_diag` read report the + /// previous policy after installation), and the read must stay off the + /// main actor so the verb keeps working when the main thread is wedged. + /// Both critical sections are tiny value copies with no reentrancy. + private nonisolated static let relayDiagMirror = OSAllocatedUnfairLock( + initialState: RelayDiagMirror(policy: nil, refreshFailure: nil) + ) + + struct RelayDiagMirror: Equatable, Sendable { + var policy: RelayDiagState? + var refreshFailure: RelayDiagRefreshFailure? + } + + /// The single write funnel, called from the `relayPolicyEffective` and + /// `relayPolicyDiagnostics` `didSet`s so every installation, clearing, + /// and refresh-outcome site is mirrored before the property write + /// returns. + static func publishRelayDiagMirror( + from policy: CmxIrohEffectiveRelayPolicy?, + diagnostics: CmxIrohRelayDiagnosticsSnapshot? + ) { + let refreshFailure = diagnostics?.refreshFailingSince.map { + RelayDiagRefreshFailure( + since: $0, + consecutiveFailures: diagnostics?.consecutiveRefreshFailures ?? 0 + ) + } + let state = policy.map { + RelayDiagState( + source: $0.source, + usedCachedPolicy: $0.usedCachedPolicy, + relayURLs: $0.endpointRelayProfile.allowedRelayURLs.sorted(), + refreshFailingSince: refreshFailure?.since, + consecutiveRefreshFailures: refreshFailure?.consecutiveFailures ?? 0 + ) + } + relayDiagMirror.withLock { + $0 = RelayDiagMirror(policy: state, refreshFailure: refreshFailure) + } + } + + nonisolated static func currentRelayDiagState() -> RelayDiagState? { + relayDiagMirror.withLock { $0.policy } + } + + /// The relay section appended to `iroh_diag` output: the profile the + /// endpoint is actually using, and whether it came from the managed + /// catalog, a custom profile, or the debug override. The override is + /// consulted first because every profile installation funnel replaces + /// the installed profile with it while it is active. + nonisolated static func relayDiagReportText() -> String { + let mirror = relayDiagMirror.withLock { $0 } + return relayDiagReport( + policy: mirror.policy, + refreshFailure: mirror.refreshFailure, + debugOverrideRelayURL: CmxIrohDebugRelayOverrideDiagnostics().activeRelayURL + ) + } + + nonisolated static func relayDiagReport( + policy: RelayDiagState?, + refreshFailure: RelayDiagRefreshFailure? = nil, + debugOverrideRelayURL: String? + ) -> String { + var lines = ["Active relay profile"] + if let debugOverrideRelayURL { + let key = CmxIrohDebugRelayOverrideDiagnostics().overrideKey + lines.append("Source: debug override (\(key))") + lines.append("Relays: \(debugOverrideRelayURL)") + return lines.joined(separator: "\n") + } + guard let policy else { + if let refreshFailure { + lines.append( + "Source: none — policy refresh failing since " + + iso8601(refreshFailure.since) + + " (\(refreshFailure.consecutiveFailures) consecutive failures)" + ) + } else { + lines.append("Source: none installed (no relay policy this launch)") + } + return lines.joined(separator: "\n") + } + let source = switch policy.source { + case .inactive: + "inactive (no account policy restored)" + case .managed: + policy.usedCachedPolicy ? "managed catalog (cached)" : "managed catalog" + case .custom: + "custom" + case .managedUnavailable: + "managed selection unavailable (relays disabled)" + case .customUnavailable: + "custom selection unavailable (relays disabled)" + } + lines.append("Source: \(source)") + if policy.relayURLs.isEmpty { + lines.append("Relays: (none)") + } else { + lines.append("Relays: \(policy.relayURLs.joined(separator: ", "))") + } + if let since = policy.refreshFailingSince { + lines.append( + "Policy refresh: failing since " + iso8601(since) + + " (\(policy.consecutiveRefreshFailures) consecutive failures)" + ) + } + return lines.joined(separator: "\n") + } + + private nonisolated static func iso8601(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } +} diff --git a/Sources/Mobile/MobileHostIrohRuntime+SettingsControl.swift b/Sources/Mobile/MobileHostIrohRuntime+SettingsControl.swift index 532cbff1f8c3..9a4a4ff20e0e 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+SettingsControl.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+SettingsControl.swift @@ -198,7 +198,6 @@ extension MobileHostIrohRuntime: CmxIrohSettingsControlling { diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { let effective = try await context.service.refresh( - endpointID: context.endpointID, accountID: context.accountID, trustRoot: context.trustRoot, now: Date() @@ -348,7 +347,6 @@ extension MobileHostIrohRuntime: CmxIrohSettingsControlling { self.diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { let effective = try await service.refresh( - endpointID: endpointID, accountID: accountID, trustRoot: trustRoot, now: Date() @@ -460,7 +458,6 @@ extension MobileHostIrohRuntime: CmxIrohSettingsControlling { ) async { do { let effective = try await context.service.refresh( - endpointID: context.endpointID, accountID: context.accountID, trustRoot: context.trustRoot, now: Date() diff --git a/Sources/Mobile/MobileHostIrohRuntime+SettingsSnapshot.swift b/Sources/Mobile/MobileHostIrohRuntime+SettingsSnapshot.swift index 1914519f76dc..0260714628d7 100644 --- a/Sources/Mobile/MobileHostIrohRuntime+SettingsSnapshot.swift +++ b/Sources/Mobile/MobileHostIrohRuntime+SettingsSnapshot.swift @@ -36,6 +36,8 @@ extension MobileHostIrohRuntime { runtimeStatus: Self.settingsRuntimeStatus( runtimeState, failure: diagnostics?.failure, + refreshFailurePersistent: (diagnostics?.consecutiveRefreshFailures ?? 0) + >= CmxIrohRelayPolicyService.persistentRefreshFailureThreshold, selectedPath: selectedPath ), selectedTransportPath: selectedPath, @@ -66,9 +68,14 @@ extension MobileHostIrohRuntime { private nonisolated static func settingsRuntimeStatus( _ state: CmxIrohHostRuntimeSnapshot.State?, failure: CmxIrohRelayPolicyFailure?, + refreshFailurePersistent: Bool, selectedPath: CmxIrohSelectedTransportPath ) -> CmxIrohSettingsSnapshot.RuntimeStatus { if failure != nil { return .degraded } + // A persistently failing policy refresh means this host cannot renew + // relay authority: without this the outage was invisible while LAN + // and direct paths still worked (cmux#10873). + if refreshFailurePersistent { return .degraded } switch state { case .active: return CmxIrohSettingsSnapshot.RuntimeStatus(activePath: selectedPath) diff --git a/Sources/Mobile/MobileHostIrohRuntime.swift b/Sources/Mobile/MobileHostIrohRuntime.swift index 3b1b07ce5332..04aa410b23c0 100644 --- a/Sources/Mobile/MobileHostIrohRuntime.swift +++ b/Sources/Mobile/MobileHostIrohRuntime.swift @@ -108,8 +108,22 @@ final class MobileHostIrohRuntime { var transitionTask: Task? var runtime: CmxIrohHostRuntime? var relayPolicyService: CmxIrohRelayPolicyService? - var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? - var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? + var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? { + didSet { + Self.publishRelayDiagMirror( + from: relayPolicyEffective, + diagnostics: relayPolicyDiagnostics + ) + } + } + var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? { + didSet { + Self.publishRelayDiagMirror( + from: relayPolicyEffective, + diagnostics: relayPolicyDiagnostics + ) + } + } var relayPolicyEndpointID: CmxIrohPeerIdentity? var relayPolicyObservationTask: Task? var relayPolicyRefreshTask: Task? diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 33f1549af7e6..01f88e048798 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11696,7 +11696,9 @@ class TerminalController { /// Serves the v1 `iroh_diag` socket command: the host's Iroh Connection /// Report in the same plain-language format the Settings pane exports, /// read from the same `DiagnosticLog` snapshot path so the two can never - /// disagree. + /// disagree, plus an active-relay section (profile source and URLs) that + /// exists only in the local debug socket output because relay URLs are + /// deliberately excluded from the privacy-safe exported report. private nonisolated func irohDiagText() -> String { let semaphore = DispatchSemaphore(value: 0) nonisolated(unsafe) var export = "" @@ -11711,7 +11713,10 @@ class TerminalController { semaphore.signal() } semaphore.wait() - return export + // Appended outside the report so relay URLs never enter the + // privacy-safe DiagnosticLog pipeline; the mirror read is serialized + // and main-actor-free. + return export + "\n" + MobileHostIrohRuntime.relayDiagReportText() + "\n" } private nonisolated func readScreenText(_ args: String) -> String { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f955d975a3fb..7c3b23ab46d4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -253,6 +253,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A7206E010000000000000001 /* AppIconAppearanceObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206E020000000000000001 /* AppIconAppearanceObserverTests.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; + 1B0B09980000000000000002 /* AppTerminationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09980000000000000001 /* AppTerminationRequest.swift */; }; A5001A02A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001A03A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift */; }; A5001A00A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001A01A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; @@ -1447,6 +1448,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C1A070000000000000000002 /* MobileHostIrohAuthObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000012 /* MobileHostIrohAuthObserver.swift */; }; 1B0B09020000000000000002 /* MobileHostIrohRuntime+Activation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */; }; C1A070000000000000000003 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000013 /* MobileHostIrohRuntime+Lifecycle.swift */; }; + 1B0B09990000000000000002 /* MobileHostIrohRuntime+RelayDiag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */; }; 1B0B09030000000000000002 /* MobileHostIrohRuntime+SettingsControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */; }; 1B0B09040000000000000002 /* MobileHostIrohRuntime+SettingsSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */; }; 1B0B09010000000000000002 /* MobileHostIrohRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */; }; @@ -3164,6 +3166,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7206E020000000000000001 /* AppIconAppearanceObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconAppearanceObserverTests.swift; sourceTree = ""; }; D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = ""; }; A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; + 1B0B09980000000000000001 /* AppTerminationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppTerminationRequest.swift; sourceTree = ""; }; A5001A03A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/AppWindowBackdropControllerDependencies.swift; sourceTree = ""; }; A5001A01A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/AppWindowChromeComposition.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -4273,6 +4276,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C1A070000000000000000012 /* MobileHostIrohAuthObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohAuthObserver.swift; sourceTree = ""; }; 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+Activation.swift"; sourceTree = ""; }; C1A070000000000000000013 /* MobileHostIrohRuntime+Lifecycle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+Lifecycle.swift"; sourceTree = ""; }; + 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+RelayDiag.swift"; sourceTree = ""; }; 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+SettingsControl.swift"; sourceTree = ""; }; 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+SettingsSnapshot.swift"; sourceTree = ""; }; 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohRuntime.swift; sourceTree = ""; }; @@ -5968,6 +5972,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A2DBE587F52C8A3B2A2CFCB8 /* MobileStateSync.swift */, 1B0B09010000000000000001 /* MobileHostIrohRuntime.swift */, 1B0B09020000000000000001 /* MobileHostIrohRuntime+Activation.swift */, + 1B0B09990000000000000001 /* MobileHostIrohRuntime+RelayDiag.swift */, 1B0B09030000000000000001 /* MobileHostIrohRuntime+SettingsControl.swift */, 1B0B09040000000000000001 /* MobileHostIrohRuntime+SettingsSnapshot.swift */, C1A070000000000000000011 /* MobileHostAuthorizationSupport.swift */, @@ -7000,6 +7005,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + 1B0B09980000000000000001 /* AppTerminationRequest.swift */, C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */, D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */, D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */, @@ -9291,6 +9297,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A11EAB000000000000000000 /* AppearanceSettings.swift in Sources */, C97160000000000000000001 /* AppHostProcessReceipt.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, + 1B0B09980000000000000002 /* AppTerminationRequest.swift in Sources */, A5001A02A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift in Sources */, A5001A00A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift in Sources */, 961300000000000000000003 /* AttributedString+SidebarRowLinks.swift in Sources */, @@ -9943,6 +9950,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C1A070000000000000000002 /* MobileHostIrohAuthObserver.swift in Sources */, 1B0B09020000000000000002 /* MobileHostIrohRuntime+Activation.swift in Sources */, C1A070000000000000000003 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */, + 1B0B09990000000000000002 /* MobileHostIrohRuntime+RelayDiag.swift in Sources */, 1B0B09030000000000000002 /* MobileHostIrohRuntime+SettingsControl.swift in Sources */, 1B0B09040000000000000002 /* MobileHostIrohRuntime+SettingsSnapshot.swift in Sources */, 1B0B09010000000000000002 /* MobileHostIrohRuntime.swift in Sources */, @@ -11984,6 +11992,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug"; CMUX_AUTH_CALLBACK_SCHEME = "cmux-dev"; + CMUX_IROH_RELAY_POLICY_EXTRA_KEY_ID = "cmux-itest-relay-policy-2026-08"; + CMUX_IROH_RELAY_POLICY_EXTRA_PUBLIC_KEY_BASE64 = "U3i4OPs1uJB00dClzigfPGxi0KCEelvAOqAoKU35fxo="; CMUX_IROH_RELAY_POLICY_KEY_ID = "cmux-staging-relay-policy-2026-07"; CMUX_IROH_RELAY_POLICY_NEXT_KEY_ID = "cmux-staging-relay-policy-2026-08"; CMUX_IROH_RELAY_POLICY_NEXT_PUBLIC_KEY_BASE64 = "KnOZ6gKmH05Mrfan2tXgwRygBKxcSUue4bp34udiQFA="; diff --git a/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift b/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift index d8fc565f0227..12cbf31a0fbe 100644 --- a/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift +++ b/cmuxTests/ExtensionWorktreeSpawnArgsTests.swift @@ -28,6 +28,8 @@ struct ExtensionWorktreeSpawnArgsTests { createdHead: "0000000000000000000000000000000000000000", generatedArtifactRelativePath: "cmux-sample-dev/index.html", generatedArtifactContents: Data(), + worktreeDeviceID: nil, + worktreeFileID: nil, setupCommand: setupCommand ) } @@ -359,7 +361,7 @@ struct ExtensionWorktreeSpawnArgsTests { let mutationStatus = await withTaskGroup(of: Int32?.self, returning: Int32?.self) { group in group.addTask { var mutationIterator = mutation.stream.makeAsyncIterator() - await mutationIterator.next() + return await mutationIterator.next() } group.addTask { try? await Task.sleep(for: .seconds(5)) diff --git a/cmuxTests/MobileHostServiceSettingsTests.swift b/cmuxTests/MobileHostServiceSettingsTests.swift index 366b3fca855c..ed4ad5d89050 100644 --- a/cmuxTests/MobileHostServiceSettingsTests.swift +++ b/cmuxTests/MobileHostServiceSettingsTests.swift @@ -447,3 +447,99 @@ struct MobileHostMacScopedMutationAuthorizationTests { } #endif + +@Suite +struct MobileHostIrohRelayDiagReportTests { + @Test func debugOverrideWinsOverInstalledPolicy() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: MobileHostIrohRuntime.RelayDiagState( + source: .managed, + usedCachedPolicy: false, + relayURLs: ["https://relay.cmux.io/"] + ), + debugOverrideRelayURL: "https://test-relay.example/" + ) + #expect(text == """ + Active relay profile + Source: debug override (CMUX_IROH_RELAY_URL_OVERRIDE) + Relays: https://test-relay.example/ + """) + } + + @Test func managedCatalogReportsCachednessAndURLs() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: MobileHostIrohRuntime.RelayDiagState( + source: .managed, + usedCachedPolicy: true, + relayURLs: ["https://a.example/", "https://b.example/"] + ), + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: managed catalog (cached) + Relays: https://a.example/, https://b.example/ + """) + } + + @Test func customProfileReportsCustomSource() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: MobileHostIrohRuntime.RelayDiagState( + source: .custom, + usedCachedPolicy: false, + relayURLs: ["https://my-relay.example/"] + ), + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: custom + Relays: https://my-relay.example/ + """) + } + + @Test func missingPolicyReportsNoneInstalled() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: nil, + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: none installed (no relay policy this launch) + """) + } + + @Test func missingPolicyWithFailingRefreshReportsFailingSince() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: nil, + refreshFailure: MobileHostIrohRuntime.RelayDiagRefreshFailure( + since: Date(timeIntervalSince1970: 1_782_000_000), + consecutiveFailures: 4 + ), + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: none — policy refresh failing since 2026-06-21T00:00:00Z (4 consecutive failures) + """) + } + + @Test func installedPolicyWithFailingRefreshAppendsFailureLine() { + let text = MobileHostIrohRuntime.relayDiagReport( + policy: MobileHostIrohRuntime.RelayDiagState( + source: .managedUnavailable, + usedCachedPolicy: false, + relayURLs: [], + refreshFailingSince: Date(timeIntervalSince1970: 1_782_000_000), + consecutiveRefreshFailures: 7 + ), + debugOverrideRelayURL: nil + ) + #expect(text == """ + Active relay profile + Source: managed selection unavailable (relays disabled) + Relays: (none) + Policy refresh: failing since 2026-06-21T00:00:00Z (7 consecutive failures) + """) + } +} diff --git a/cmuxTests/QuitConfirmationAlertPresenterTests.swift b/cmuxTests/QuitConfirmationAlertPresenterTests.swift index 0db12c56ee17..4897e27478ac 100644 --- a/cmuxTests/QuitConfirmationAlertPresenterTests.swift +++ b/cmuxTests/QuitConfirmationAlertPresenterTests.swift @@ -258,3 +258,27 @@ private final class QuitConfirmationAlertSpy: NSAlert { return .alertSecondButtonReturn } } + +@MainActor +@Suite +struct AppTerminationRequestDispatchTests { + /// Regression for https://github.com/manaflow-ai/cmux/issues/10788: a + /// debug-socket `simulate_shortcut cmd+q` runs the quit path inside a + /// `DispatchQueue.main.sync` block (`v2MainSync`). Terminating + /// synchronously from there deadlocks: `applicationShouldTerminate` + /// returns `.terminateLater` and its deferred `@MainActor` cleanup task + /// can never start while the main queue is still inside the socket + /// command block. The terminate request must therefore leave the + /// caller's turn and fire from a later main-run-loop callout. + @Test + func scheduledTerminateFiresFromALaterRunLoopCallout_notInsideTheRequestingBlock() { + var fired = false + AppTerminationRequest.schedule { fired = true } + #expect(!fired, "terminate ran synchronously inside the requesting block; this is the issue #10788 deadlock shape") + let deadline = Date().addingTimeInterval(5) + while !fired, Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + } + #expect(fired, "the scheduled terminate request never fired on a later run-loop turn") + } +} diff --git a/docs/iroh-app-transport-architecture.md b/docs/iroh-app-transport-architecture.md index 539cf0a1cee6..9da8afc2c418 100644 --- a/docs/iroh-app-transport-architecture.md +++ b/docs/iroh-app-transport-architecture.md @@ -115,7 +115,7 @@ Every catalog has a strictly increasing sequence and at most sixteen unique cred The server may add, remove, or replace relays without a client update. A remote `EndpointAddr` contains only the remote endpoint's advertised home relay or relays, validated against the signed fleet. Fleet configuration and remote reachability remain separate wire fields. -A signed-in native client calls `POST /api/relay/token` with its canonical EndpointID. The web API returns a five-minute endpoint-bound relay JWT, the signed policy, and the account preference. Each cmux relay verifies its JWT offline. The app refreshes before expiry and replaces the verified relay policy on the live endpoint without changing EndpointID or application streams. +A signed-in native client calls `GET /api/relay/policy`. The web API returns the signed policy and the account preference; clients hold no relay credentials. Relay admission is server-side: the relay's allow hook (`POST /api/relay/allow`) checks the endpoint key proven in the iroh handshake and caches the answer. The app refreshes the signed policy before its expiry and replaces the verified relay policy on the live endpoint without changing EndpointID or application streams. Relay preferences are personal-account scoped: @@ -172,7 +172,7 @@ Before defaulting to Iroh, verification must cover: - public direct, managed-relay, post-admission NAT-traversed LAN/Tailscale/custom-VPN candidates, authenticated Bonjour LAN bootstrap, and hardened numeric Tailscale TCP compatibility paths; - TCP-only firewalls, blocked UDP, captive portals, constrained paths, and expensive cellular paths; - explicit HTTP-proxy-only networks, with a clear legacy/private-network fallback until Iroh relay WebSockets support proxy-controlled connection establishment; -- relay token denial, expiry, refresh, and long-lived stream preservation; +- relay allow-hook denial, signed-policy expiry and refresh, and long-lived stream preservation; - background and foreground endpoint recreation with stable EndpointID; - a deterministic failed-rebind/network-resume test that proves the health watchdog detects terminal driver failure and recreates the endpoint from the same key and identity generation with a new runtime generation; - a malicious pre-admission QNT peer, proving zero candidate disclosure, `REACH_OUT` probes, timers, or migration before activation and same-connection migration after both admitted sides activate; diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist index ace55487c478..e845b331081a 100644 --- a/ios/Config/Info.plist +++ b/ios/Config/Info.plist @@ -136,6 +136,12 @@ publicKeyBase64 $(CMUX_IROH_RELAY_POLICY_NEXT_PUBLIC_KEY_BASE64) + + keyID + $(CMUX_IROH_RELAY_POLICY_EXTRA_KEY_ID) + publicKeyBase64 + $(CMUX_IROH_RELAY_POLICY_EXTRA_PUBLIC_KEY_BASE64) + diff --git a/ios/cmux-ios.xcodeproj/project.pbxproj b/ios/cmux-ios.xcodeproj/project.pbxproj index 373bc8f5fdc7..0c96b291a794 100644 --- a/ios/cmux-ios.xcodeproj/project.pbxproj +++ b/ios/cmux-ios.xcodeproj/project.pbxproj @@ -474,6 +474,8 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; + CMUX_IROH_RELAY_POLICY_EXTRA_KEY_ID = "cmux-itest-relay-policy-2026-08"; + CMUX_IROH_RELAY_POLICY_EXTRA_PUBLIC_KEY_BASE64 = "U3i4OPs1uJB00dClzigfPGxi0KCEelvAOqAoKU35fxo="; CMUX_IROH_RELAY_POLICY_KEY_ID = "cmux-staging-relay-policy-2026-07"; CMUX_IROH_RELAY_POLICY_NEXT_KEY_ID = "cmux-staging-relay-policy-2026-08"; CMUX_IROH_RELAY_POLICY_NEXT_PUBLIC_KEY_BASE64 = "KnOZ6gKmH05Mrfan2tXgwRygBKxcSUue4bp34udiQFA="; diff --git a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateHostView.swift b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateHostView.swift index 59f0be30af25..e1c6d67f333b 100644 --- a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateHostView.swift +++ b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateHostView.swift @@ -17,16 +17,12 @@ struct MobileIrohReleaseGateHostView: View { configuration: MobileIrohReleaseGateRunner.Configuration, onboardingStore: MobileOnboardingStore, signOutHook: MobileSignOutHook, - settingsController: any CmxIrohSettingsControlling, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity?, - relayCredentialExpiry: @escaping @Sendable () async -> Date? + settingsController: any CmxIrohSettingsControlling ) { _store = State(initialValue: store) _runner = State(initialValue: MobileIrohReleaseGateRunner( configuration: configuration, - settingsController: settingsController, - endpointIdentity: endpointIdentity, - relayCredentialExpiry: relayCredentialExpiry + settingsController: settingsController )) self.onboardingStore = onboardingStore self.signOutHook = signOutHook diff --git a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateRunner.swift b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateRunner.swift index 134368206d08..6f6554025018 100644 --- a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateRunner.swift +++ b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateRunner.swift @@ -15,20 +15,16 @@ private let mobileIrohReleaseGateLog = Logger( @MainActor final class MobileIrohReleaseGateRunner { - private static let relayRolloverSoakDurationSeconds = 330 private static let requiredReadyObservations = 2 private static let readinessSettlingDuration: Duration = .milliseconds(500) private static let standardTimeout: Duration = .seconds(90) - private static let extendedTimeout: Duration = .seconds(420) struct Configuration: Equatable, Sendable { static let modeEnvironmentKey = "CMUX_IROH_RELEASE_GATE_MODE" - static let scenarioEnvironmentKey = "CMUX_IROH_RELEASE_GATE_SCENARIO" static let reportFilename = "cmux-iroh-release-gate.json" static let reportReadyNotification = "dev.cmux.ios.iroh-release-gate.report-ready" let mode: CmxIrohTransportVerificationMode - let scenario: MobileIrohReleaseGateScenario let reportURL: URL init?( @@ -40,18 +36,7 @@ final class MobileIrohReleaseGateRunner { let cachesDirectory else { return nil } - let scenario: MobileIrohReleaseGateScenario - if let rawScenario = environment[Self.scenarioEnvironmentKey] { - guard let parsed = MobileIrohReleaseGateScenario(rawValue: rawScenario) else { - return nil - } - scenario = parsed - } else { - scenario = .standard - } - guard scenario == .standard || mode == .relayOnly else { return nil } self.mode = mode - self.scenario = scenario self.reportURL = cachesDirectory.appendingPathComponent(Self.reportFilename) } @@ -72,7 +57,6 @@ final class MobileIrohReleaseGateRunner { struct Report: Codable, Equatable, Sendable { let schemaVersion: Int let mode: String - let scenario: String let passed: Bool let hostStatusVerified: Bool let rpcMethodInventoryVerified: Bool @@ -82,14 +66,6 @@ final class MobileIrohReleaseGateRunner { let notificationReconcileVerified: Bool let chatSessionsVerified: Bool let artifactScanCountVerified: Bool - let relayCredentialRolloverVerified: Bool - let endpointContinuityVerified: Bool - let connectionContinuityVerified: Bool - let controlStreamContinuityVerified: Bool - let independentEventsContinuityVerified: Bool - let artifactLaneVerified: Bool - let unrefreshedExpiryDisconnectVerified: Bool - let soakDurationSeconds: Int let routeKind: String? let selectedPath: String? let failure: String? @@ -178,8 +154,6 @@ final class MobileIrohReleaseGateRunner { init( configuration: Configuration, settingsController: any CmxIrohSettingsControlling, - endpointIdentity: @escaping @Sendable () async -> CmxIrohPeerIdentity? = { nil }, - relayCredentialExpiry: @escaping @Sendable () async -> Date? = { nil }, fileManager: FileManager = .default ) { self.configuration = configuration @@ -187,15 +161,7 @@ final class MobileIrohReleaseGateRunner { self.dependencies = Dependencies( readinessUpdates: nil, runProbe: { store, marker in - try await store.runIrohReleaseGateProbe( - marker: marker, - scenario: configuration.scenario, - soakDurationSeconds: configuration.scenario == .relayRollover - ? Self.relayRolloverSoakDurationSeconds - : 0, - endpointIdentity: endpointIdentity, - relayCredentialExpiry: relayCredentialExpiry - ) + try await store.runIrohReleaseGateProbe(marker: marker) }, settingsUpdates: { settingsController.irohSettingsUpdates() @@ -209,9 +175,7 @@ final class MobileIrohReleaseGateRunner { postReportReady: { Self.postReportReadyNotification() }, - timeout: configuration.scenario == .standard - ? Self.standardTimeout - : Self.extendedTimeout + timeout: Self.standardTimeout ) } @@ -279,14 +243,12 @@ final class MobileIrohReleaseGateRunner { private func boundedReport(store: CMUXMobileShellStore) async -> Report { let mode = configuration.mode - let scenario = configuration.scenario let timeout = dependencies.timeout let reports = AsyncStream(bufferingPolicy: .bufferingOldest(1)) { continuation in let operationTask = Task { @MainActor [weak self] in guard let self else { continuation.yield(Self.failureReport( mode: mode, - scenario: scenario, failure: .unknownProbeFailure )) continuation.finish() @@ -306,7 +268,6 @@ final class MobileIrohReleaseGateRunner { let deadline = await self.deadlineFailure() continuation.yield(Self.failureReport( mode: mode, - scenario: scenario, failure: deadline.failure, completedProbe: self.completedProbe, lastDiagnosticEvent: deadline.lastDiagnosticEvent @@ -324,7 +285,6 @@ final class MobileIrohReleaseGateRunner { } return Self.failureReport( mode: mode, - scenario: scenario, failure: .unknownProbeFailure ) } @@ -343,7 +303,6 @@ final class MobileIrohReleaseGateRunner { guard !Task.isCancelled else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout ) } @@ -355,14 +314,12 @@ final class MobileIrohReleaseGateRunner { guard !Task.isCancelled else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout ) } guard observedReady else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .readinessUnavailable ) } @@ -374,7 +331,6 @@ final class MobileIrohReleaseGateRunner { } catch { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout ) } @@ -384,44 +340,9 @@ final class MobileIrohReleaseGateRunner { guard !Task.isCancelled else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout ) } - var pathBeforeProbe: String? - if configuration.scenario != .standard { - for await snapshot in dependencies.settingsUpdates() { - guard !Task.isCancelled else { - return Self.failureReport( - mode: configuration.mode, - scenario: configuration.scenario, - failure: .timeout - ) - } - if let accepted = Self.acceptedPath( - snapshot.selectedTransportPath, - mode: configuration.mode - ) { - pathBeforeProbe = accepted - break - } - } - guard !Task.isCancelled else { - return Self.failureReport( - mode: configuration.mode, - scenario: configuration.scenario, - failure: .timeout - ) - } - guard pathBeforeProbe != nil else { - return Self.failureReport( - mode: configuration.mode, - scenario: configuration.scenario, - failure: .pathPolicyMismatch - ) - } - } - let marker = "CMUX_IROH_GATE_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" let probe: MobileIrohReleaseGateProbeResult do { @@ -429,34 +350,22 @@ final class MobileIrohReleaseGateRunner { } catch let failure as MobileIrohReleaseGateProbeFailure { return Self.probeFailureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: failure, - selectedPath: pathBeforeProbe + selectedPath: nil ) } catch { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .unknownProbeFailure ) } completedProbe = probe - if let pathBeforeProbe { - return Self.completedReport( - mode: configuration.mode, - scenario: configuration.scenario, - probe: probe, - selectedPath: pathBeforeProbe - ) - } - let snapshots = dependencies.settingsUpdates() for await snapshot in snapshots { guard !Task.isCancelled else { return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .timeout, completedProbe: probe ) @@ -472,7 +381,6 @@ final class MobileIrohReleaseGateRunner { observationID = nil return Self.completedReport( mode: configuration.mode, - scenario: configuration.scenario, probe: probe, selectedPath: selectedPath ) @@ -480,7 +388,6 @@ final class MobileIrohReleaseGateRunner { } return Self.failureReport( mode: configuration.mode, - scenario: configuration.scenario, failure: .pathPolicyMismatch, completedProbe: probe ) @@ -606,36 +513,14 @@ final class MobileIrohReleaseGateRunner { } } - private static func scenarioPassed( - _ scenario: MobileIrohReleaseGateScenario, - probe: MobileIrohReleaseGateProbeResult - ) -> Bool { - switch scenario { - case .standard: - return true - case .relayRollover: - return probe.relayCredentialRolloverVerified - && probe.endpointContinuityVerified - && probe.connectionContinuityVerified - && probe.controlStreamContinuityVerified - && probe.independentEventsContinuityVerified - && probe.artifactLaneVerified - && probe.soakDurationSeconds >= relayRolloverSoakDurationSeconds - case .relayExpiry: - return probe.unrefreshedExpiryDisconnectVerified - } - } - private static func completedReport( mode: CmxIrohTransportVerificationMode, - scenario: MobileIrohReleaseGateScenario, probe: MobileIrohReleaseGateProbeResult, selectedPath: String ) -> Report { Report( - schemaVersion: 4, + schemaVersion: 5, mode: mode.rawValue, - scenario: scenario.rawValue, passed: probe.hostStatusVerified && probe.rpcMethodInventoryVerified && probe.terminalRoundTripVerified @@ -643,8 +528,7 @@ final class MobileIrohReleaseGateRunner { && probe.independentEventsVerified && probe.notificationReconcileVerified && probe.chatSessionsVerified - && probe.artifactScanCountVerified - && scenarioPassed(scenario, probe: probe), + && probe.artifactScanCountVerified, hostStatusVerified: probe.hostStatusVerified, rpcMethodInventoryVerified: probe.rpcMethodInventoryVerified, terminalRoundTripVerified: probe.terminalRoundTripVerified, @@ -653,14 +537,6 @@ final class MobileIrohReleaseGateRunner { notificationReconcileVerified: probe.notificationReconcileVerified, chatSessionsVerified: probe.chatSessionsVerified, artifactScanCountVerified: probe.artifactScanCountVerified, - relayCredentialRolloverVerified: probe.relayCredentialRolloverVerified, - endpointContinuityVerified: probe.endpointContinuityVerified, - connectionContinuityVerified: probe.connectionContinuityVerified, - controlStreamContinuityVerified: probe.controlStreamContinuityVerified, - independentEventsContinuityVerified: probe.independentEventsContinuityVerified, - artifactLaneVerified: probe.artifactLaneVerified, - unrefreshedExpiryDisconnectVerified: probe.unrefreshedExpiryDisconnectVerified, - soakDurationSeconds: probe.soakDurationSeconds, routeKind: CmxAttachTransportKind.iroh.rawValue, selectedPath: selectedPath, failure: nil, @@ -690,14 +566,12 @@ final class MobileIrohReleaseGateRunner { private static func probeFailureReport( mode: CmxIrohTransportVerificationMode, - scenario: MobileIrohReleaseGateScenario, failure: MobileIrohReleaseGateProbeFailure, selectedPath: String? ) -> Report { Report( - schemaVersion: 4, + schemaVersion: 5, mode: mode.rawValue, - scenario: scenario.rawValue, passed: false, hostStatusVerified: false, rpcMethodInventoryVerified: false, @@ -707,14 +581,6 @@ final class MobileIrohReleaseGateRunner { notificationReconcileVerified: false, chatSessionsVerified: false, artifactScanCountVerified: false, - relayCredentialRolloverVerified: false, - endpointContinuityVerified: false, - connectionContinuityVerified: false, - controlStreamContinuityVerified: false, - independentEventsContinuityVerified: false, - artifactLaneVerified: false, - unrefreshedExpiryDisconnectVerified: false, - soakDurationSeconds: 0, routeKind: CmxAttachTransportKind.iroh.rawValue, selectedPath: selectedPath, failure: failure.rawValue, @@ -725,15 +591,13 @@ final class MobileIrohReleaseGateRunner { private nonisolated static func failureReport( mode: CmxIrohTransportVerificationMode, - scenario: MobileIrohReleaseGateScenario, failure: Failure, completedProbe: MobileIrohReleaseGateProbeResult? = nil, lastDiagnosticEvent: DiagnosticEvent? = nil ) -> Report { Report( - schemaVersion: 4, + schemaVersion: 5, mode: mode.rawValue, - scenario: scenario.rawValue, passed: false, hostStatusVerified: completedProbe?.hostStatusVerified ?? false, rpcMethodInventoryVerified: completedProbe?.rpcMethodInventoryVerified ?? false, @@ -743,14 +607,6 @@ final class MobileIrohReleaseGateRunner { notificationReconcileVerified: completedProbe?.notificationReconcileVerified ?? false, chatSessionsVerified: completedProbe?.chatSessionsVerified ?? false, artifactScanCountVerified: completedProbe?.artifactScanCountVerified ?? false, - relayCredentialRolloverVerified: completedProbe?.relayCredentialRolloverVerified ?? false, - endpointContinuityVerified: completedProbe?.endpointContinuityVerified ?? false, - connectionContinuityVerified: completedProbe?.connectionContinuityVerified ?? false, - controlStreamContinuityVerified: completedProbe?.controlStreamContinuityVerified ?? false, - independentEventsContinuityVerified: completedProbe?.independentEventsContinuityVerified ?? false, - artifactLaneVerified: completedProbe?.artifactLaneVerified ?? false, - unrefreshedExpiryDisconnectVerified: completedProbe?.unrefreshedExpiryDisconnectVerified ?? false, - soakDurationSeconds: completedProbe?.soakDurationSeconds ?? 0, routeKind: completedProbe == nil ? nil : CmxAttachTransportKind.iroh.rawValue, selectedPath: nil, failure: failure.rawValue, diff --git a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateScene.swift b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateScene.swift index 54efb4c30d37..447362d7d43c 100644 --- a/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateScene.swift +++ b/ios/cmuxPackage/Sources/CmuxIrohReleaseGateSupport/MobileIrohReleaseGateScene.swift @@ -28,11 +28,7 @@ public struct MobileIrohReleaseGateScene: View { configuration: configuration, onboardingStore: root.onboardingStore, signOutHook: root.signOutHook, - settingsController: iroh, - endpointIdentity: { await iroh.releaseGateEndpointIdentity() }, - relayCredentialExpiry: { - await iroh.releaseGateRelayCredentialExpiry() - } + settingsController: iroh ) ) } else { diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition+ReleaseGate.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition+ReleaseGate.swift index b2e1293c6bf6..b3efc8b8bb8d 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition+ReleaseGate.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition+ReleaseGate.swift @@ -8,10 +8,5 @@ public extension MobileIrohRuntimeComposition { func releaseGateEndpointIdentity() async -> CmxIrohPeerIdentity? { await runtime?.snapshot().endpointID } - - /// Supplies the non-secret installed relay expiry to the release gate. - func releaseGateRelayCredentialExpiry() async -> Date? { - await runtime?.relayCredentialExpiresAt() - } } #endif diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift index 723a3a35e4fe..a2aeb60cb139 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift @@ -1767,35 +1767,15 @@ public final class MobileIrohRuntimeComposition: && $0.endpointID == endpointID && $0.identityGeneration == identity.generation } ?? false - let cachedManagedRelayURLs: Set - if let relayPolicyTrustRoot, - let cachedPolicy = try? await relayPolicyCache.load( - trustRoot: relayPolicyTrustRoot, - now: now() - ) { - cachedManagedRelayURLs = Set(cachedPolicy.relays.map(\.url)) - } else { - cachedManagedRelayURLs = [] - } - let cachedRelay: CmxIrohRelayTokenResponse? if let cachedBinding, bindingMatches { lastKnownBindingID = cachedBinding.bindingID lastKnownBindingAccountID = accountID lastKnownBindingTag = tag - cachedRelay = try await brokerCredentials.loadRelayCredential( + } else if cachedBinding != nil { + try? await brokerCredentials.deleteBinding( accountID: accountID, - binding: cachedBinding, - expectedRelayFleet: cachedManagedRelayURLs, - now: now() + appInstanceID: appInstanceID ) - } else { - if cachedBinding != nil { - try? await brokerCredentials.deleteBinding( - accountID: accountID, - appInstanceID: appInstanceID - ) - } - cachedRelay = nil } // Pin the activation's broker to the session identity that owns @@ -1839,7 +1819,6 @@ public final class MobileIrohRuntimeComposition: let managedRelayURLs: Set let resolvedPolicyService: CmxIrohRelayPolicyService? let resolvedEffectivePolicy: CmxIrohEffectiveRelayPolicy? - var freshRelayCredential: CmxIrohRelayTokenResponse? var relayPolicyNeedsImmediateRefresh = false if let relayPolicyTrustRoot { let service = CmxIrohRelayPolicyService( @@ -1857,21 +1836,17 @@ public final class MobileIrohRuntimeComposition: effective = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, - relayCredential: cachedRelay, now: now() ) relayPolicyNeedsImmediateRefresh = true } else { diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { - let outcome = try await service.refreshWithCredential( - endpointID: endpointID, + effective = try await service.refresh( accountID: accountID, trustRoot: relayPolicyTrustRoot, now: now() ) - effective = outcome.effective - freshRelayCredential = outcome.relayCredential diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshSucceeded)) } catch { diagnosticLog?.record(DiagnosticEvent( @@ -1881,7 +1856,6 @@ public final class MobileIrohRuntimeComposition: effective = await service.restore( accountID: accountID, trustRoot: relayPolicyTrustRoot, - relayCredential: cachedRelay, now: now() ) relayPolicyNeedsImmediateRefresh = true @@ -1907,12 +1881,6 @@ public final class MobileIrohRuntimeComposition: resolvedPolicyService = nil resolvedEffectivePolicy = nil } - let compatibleCachedRelay = cachedRelay.flatMap { relay in - Set(relay.relayFleet) == managedRelayURLs ? relay : nil - } - let freshCompatibleRelay = freshRelayCredential.flatMap { relay in - Set(relay.relayFleet) == managedRelayURLs ? relay : nil - } let configuration = CmxIrohClientRuntimeConfiguration( accountID: accountID, deviceID: deviceID, @@ -1924,14 +1892,12 @@ public final class MobileIrohRuntimeComposition: capabilities: Self.capabilities, managedRelayURLs: managedRelayURLs, endpointRelayProfile: endpointRelayProfile, - cachedRelayCredential: freshCompatibleRelay ?? compatibleCachedRelay, cachedBinding: bindingMatches ? cachedBinding : nil ) let credentialRepository = brokerCredentials let routeCatalog = routeCatalog let lanPeerDiscovery = lanPeerDiscovery let clock = now - let activeRelayPolicyService = resolvedPolicyService let transportVerificationMode = transportVerificationMode let customPrivatePaths = customPrivatePaths let networkPathSnapshotComposer = networkPathSnapshotComposer @@ -2006,7 +1972,6 @@ public final class MobileIrohRuntimeComposition: accountID: accountID ) }, - automaticRelayCredentialRefreshEnabled: automaticRelayCredentialRefreshEnabled, handleBinding: { [weak self] binding, discovery in guard await self?.allowsPersistence( accountID: accountID, @@ -2042,21 +2007,6 @@ public final class MobileIrohRuntimeComposition: ) == true else { return } await routeCatalog.replaceCachedBindings(bindings, scope: revision) }, - handleRelayCredential: { [weak self] response, binding in - guard await self?.allowsPersistence( - accountID: accountID, - revision: revision - ) == true else { return } - let expectedRelayFleet = await activeRelayPolicyService?.managedPolicy() - .map { Set($0.relays.map(\.url)) } ?? managedRelayURLs - try? await credentialRepository.saveRelayCredential( - response, - accountID: accountID, - binding: CmxIrohBrokerBindingMetadata(binding: binding), - expectedRelayFleet: expectedRelayFleet, - now: clock() - ) - }, handleLocalDeactivation: { [appInstances, identities, brokerCredentials] in await routeCatalog.deactivate(scope: revision) await lanPeerDiscovery?.stop() @@ -2755,7 +2705,6 @@ extension MobileIrohRuntimeComposition: CmxIrohSettingsControlling { diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { let effective = try await context.service.refresh( - endpointID: context.endpointID, accountID: context.accountID, trustRoot: context.trustRoot, now: now() @@ -2920,7 +2869,6 @@ extension MobileIrohRuntimeComposition: CmxIrohSettingsControlling { self.diagnosticLog?.record(DiagnosticEvent(.relayPolicyRefreshStarted)) do { let effective = try await service.refresh( - endpointID: endpointID, accountID: accountID, trustRoot: trustRoot, now: self.now() @@ -2969,9 +2917,8 @@ extension MobileIrohRuntimeComposition: CmxIrohSettingsControlling { } } - /// The signed policy bootstrap includes a fresh relay credential. Tests - /// that suspend automatic credential renewal must therefore suspend this - /// lane as well as the credential coordinator's timer. + /// Tests that suspend automatic relay refresh suspend this signed-policy + /// refresh lane (the env knob keeps its historical name). nonisolated static func shouldScheduleRelayPolicyRefresh( automaticRelayCredentialRefreshEnabled: Bool, serviceAvailable: Bool, @@ -3038,7 +2985,6 @@ extension MobileIrohRuntimeComposition: CmxIrohSettingsControlling { ) async { do { let effective = try await context.service.refresh( - endpointID: context.endpointID, accountID: context.accountID, trustRoot: context.trustRoot, now: now() diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohReleaseGateRunnerTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohReleaseGateRunnerTests.swift index a296aa657f02..2bc359322b5b 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohReleaseGateRunnerTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohReleaseGateRunnerTests.swift @@ -207,18 +207,15 @@ struct MobileIrohReleaseGateRunnerTests { } @Test - func probeFailurePreservesTheVerifiedIrohRouteAndPath() async throws { - let configuration = try temporaryConfiguration( - mode: .relayOnly, - scenario: .relayRollover - ) + func probeFailureReportsTheBoundedFailureCase() async throws { + let configuration = try temporaryConfiguration(mode: .relayOnly) var capturedReport: MobileIrohReleaseGateRunner.Report? let runner = MobileIrohReleaseGateRunner( configuration: configuration, dependencies: .init( readinessUpdates: { _ in Self.readyReadinessUpdates() }, runProbe: { _, _ in - throw MobileIrohReleaseGateProbeFailure.artifactCommandNotCompleted + throw MobileIrohReleaseGateProbeFailure.terminalRoundTripFailed }, settingsUpdates: { Self.managedRelaySettingsUpdates() }, writeReport: { report, url in @@ -235,8 +232,8 @@ struct MobileIrohReleaseGateRunnerTests { let report = try #require(capturedReport) #expect(report.passed == false) #expect(report.routeKind == CmxAttachTransportKind.iroh.rawValue) - #expect(report.selectedPath == "managed_relay") - #expect(report.failure == MobileIrohReleaseGateProbeFailure.artifactCommandNotCompleted.rawValue) + #expect(report.selectedPath == nil) + #expect(report.failure == MobileIrohReleaseGateProbeFailure.terminalRoundTripFailed.rawValue) } @Test @@ -265,35 +262,6 @@ struct MobileIrohReleaseGateRunnerTests { #expect(report.failure == "timeout") } - @Test - func rolloverScenarioRequiresEveryContinuityProof() async throws { - let incomplete = try await runScenario( - .relayRollover, - probe: Self.successfulProbe - ) - #expect(incomplete.passed == false) - - let complete = try await runScenario( - .relayRollover, - probe: Self.successfulRolloverProbe - ) - #expect(complete.passed) - #expect(complete.scenario == "relay_rollover") - #expect(complete.soakDurationSeconds == 330) - } - - @Test - func expiryScenarioAcceptsOnlyTheExpectedDisconnectProof() async throws { - let report = try await runScenario( - .relayExpiry, - probe: Self.successfulExpiryProbe - ) - - #expect(report.passed) - #expect(report.scenario == "relay_expiry") - #expect(report.unrefreshedExpiryDisconnectVerified) - } - @Test func configurationRequiresAnExplicitSupportedMode() throws { let cache = URL(fileURLWithPath: "/tmp/iroh-gate-tests", isDirectory: true) @@ -312,24 +280,7 @@ struct MobileIrohReleaseGateRunnerTests { cachesDirectory: cache )) #expect(configuration.mode == .relayOnly) - #expect(configuration.scenario == .standard) #expect(configuration.reportURL.lastPathComponent == "cmux-iroh-release-gate.json") - - let rollover = try #require(MobileIrohReleaseGateRunner.Configuration( - environment: [ - "CMUX_IROH_RELEASE_GATE_MODE": "relayOnly", - "CMUX_IROH_RELEASE_GATE_SCENARIO": "relay_rollover", - ], - cachesDirectory: cache - )) - #expect(rollover.scenario == .relayRollover) - #expect(MobileIrohReleaseGateRunner.Configuration( - environment: [ - "CMUX_IROH_RELEASE_GATE_MODE": "automatic", - "CMUX_IROH_RELEASE_GATE_SCENARIO": "relay_expiry", - ], - cachesDirectory: cache - ) == nil) } @Test(arguments: [ @@ -371,9 +322,8 @@ struct MobileIrohReleaseGateRunnerTests { @Test func encodedReportContainsNoTopologyOrIdentityFields() throws { let report = MobileIrohReleaseGateRunner.Report( - schemaVersion: 4, + schemaVersion: 5, mode: "relayOnly", - scenario: "relay_rollover", passed: true, hostStatusVerified: true, rpcMethodInventoryVerified: true, @@ -383,14 +333,6 @@ struct MobileIrohReleaseGateRunnerTests { notificationReconcileVerified: true, chatSessionsVerified: true, artifactScanCountVerified: true, - relayCredentialRolloverVerified: true, - endpointContinuityVerified: true, - connectionContinuityVerified: true, - controlStreamContinuityVerified: true, - independentEventsContinuityVerified: true, - artifactLaneVerified: true, - unrefreshedExpiryDisconnectVerified: false, - soakDurationSeconds: 330, routeKind: "iroh", selectedPath: "managed_relay", failure: nil, @@ -403,7 +345,6 @@ struct MobileIrohReleaseGateRunnerTests { #expect(Set(object.keys) == [ "schemaVersion", "mode", - "scenario", "passed", "hostStatusVerified", "rpcMethodInventoryVerified", @@ -413,14 +354,6 @@ struct MobileIrohReleaseGateRunnerTests { "notificationReconcileVerified", "chatSessionsVerified", "artifactScanCountVerified", - "relayCredentialRolloverVerified", - "endpointContinuityVerified", - "connectionContinuityVerified", - "controlStreamContinuityVerified", - "independentEventsContinuityVerified", - "artifactLaneVerified", - "unrefreshedExpiryDisconnectVerified", - "soakDurationSeconds", "routeKind", "selectedPath", "lastDiagnosticEventCode", @@ -491,39 +424,8 @@ struct MobileIrohReleaseGateRunnerTests { artifactScanCountVerified: true ) - private static let successfulRolloverProbe = MobileIrohReleaseGateProbeResult( - hostStatusVerified: true, - rpcMethodInventoryVerified: true, - terminalRoundTripVerified: true, - workspaceMutationVerified: true, - independentEventsVerified: true, - notificationReconcileVerified: true, - chatSessionsVerified: true, - artifactScanCountVerified: true, - relayCredentialRolloverVerified: true, - endpointContinuityVerified: true, - connectionContinuityVerified: true, - controlStreamContinuityVerified: true, - independentEventsContinuityVerified: true, - artifactLaneVerified: true, - soakDurationSeconds: 330 - ) - - private static let successfulExpiryProbe = MobileIrohReleaseGateProbeResult( - hostStatusVerified: true, - rpcMethodInventoryVerified: true, - terminalRoundTripVerified: true, - workspaceMutationVerified: true, - independentEventsVerified: true, - notificationReconcileVerified: true, - chatSessionsVerified: true, - artifactScanCountVerified: true, - unrefreshedExpiryDisconnectVerified: true - ) - private func temporaryConfiguration( - mode: CmxIrohTransportVerificationMode, - scenario: MobileIrohReleaseGateScenario = .standard + mode: CmxIrohTransportVerificationMode ) throws -> MobileIrohReleaseGateRunner.Configuration { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -534,40 +436,11 @@ struct MobileIrohReleaseGateRunnerTests { return try #require(MobileIrohReleaseGateRunner.Configuration( environment: [ MobileIrohReleaseGateRunner.Configuration.modeEnvironmentKey: mode.rawValue, - MobileIrohReleaseGateRunner.Configuration.scenarioEnvironmentKey: scenario.rawValue, ], cachesDirectory: directory )) } - private func runScenario( - _ scenario: MobileIrohReleaseGateScenario, - probe: MobileIrohReleaseGateProbeResult - ) async throws -> MobileIrohReleaseGateRunner.Report { - let configuration = try temporaryConfiguration( - mode: .relayOnly, - scenario: scenario - ) - var capturedReport: MobileIrohReleaseGateRunner.Report? - let runner = MobileIrohReleaseGateRunner( - configuration: configuration, - dependencies: .init( - readinessUpdates: { _ in Self.readyReadinessUpdates() }, - runProbe: { _, _ in probe }, - settingsUpdates: { Self.managedRelaySettingsUpdates() }, - writeReport: { report, url in - capturedReport = report - try Self.write(report: report, to: url) - }, - postReportReady: {}, - timeout: .seconds(1) - ) - ) - - await runner.run(store: CMUXMobileShellStore.preview()) - return try #require(capturedReport) - } - private func runLatePathFailure( settingsUpdates: AsyncStream, timeout: Duration diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift index 3918442c0bb7..9013f8e2719a 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionCooldownTests.swift @@ -274,15 +274,16 @@ struct MobileIrohRuntimeCompositionCooldownTests { #expect(await fixture.broker.totalRequestCount() == settled + 1) } + /// Activation resolves the signed policy once and never mints a relay + /// credential: the connect path is tokenless (relay allow hook). @Test - func freshRelayBootstrapCredentialAvoidsSecondMint() async throws { + func relayPolicyBootstrapMintsNothing() async throws { let fixture = try await MobileIrohCooldownFixture.makeSuccessfulBootstrap() await fixture.broker.waitForBootstrapRequest() #expect(fixture.composition.runtime != nil) #expect(await fixture.broker.bootstrapRequestCount() >= 1) - #expect(await fixture.broker.relayTokenRequestCount() == 0) } @Test @@ -754,25 +755,11 @@ private struct MobileIrohCooldownRelayPolicyFixture { ]) } - func bootstrap() throws -> CmxIrohRelayBootstrapResponse { - CmxIrohRelayBootstrapResponse( - relayToken: relayCredential(), - relayPolicy: try CmxIrohRelayPolicyResponse( - policy: signedPolicy(), - preference: .automatic, - preferenceRevision: 1 - ) - ) - } - - func relayCredential() -> CmxIrohRelayTokenResponse { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return CmxIrohRelayTokenResponse( - token: "aaaa", - expiresAt: formatter.string(from: now.addingTimeInterval(3_600)), - refreshAfter: formatter.string(from: now.addingTimeInterval(1_800)), - relayFleet: relayURLs + func bootstrap() throws -> CmxIrohRelayPolicyResponse { + try CmxIrohRelayPolicyResponse( + policy: signedPolicy(), + preference: .automatic, + preferenceRevision: 1 ) } @@ -829,12 +816,11 @@ private actor MobileIrohCooldownBroker: private let discoveryError: (any Error)? private let registration: CmxIrohRegistrationResponse private let discoveryResponse: CmxIrohDiscoveryResponse - private let bootstrap: CmxIrohRelayBootstrapResponse? + private let bootstrap: CmxIrohRelayPolicyResponse? private var relayBootstrapRetryAfterSeconds: Int? private var totalRequests = 0 private var discoveryRequests = 0 private var bootstrapRequests = 0 - private var relayTokenRequests = 0 private var suspendRelayBootstrap: Bool private var relayBootstrapContinuation: CheckedContinuation? private var bootstrapRequestWaiters: [CheckedContinuation] = [] @@ -844,7 +830,7 @@ private actor MobileIrohCooldownBroker: discoveryError: (any Error)?, registration: CmxIrohRegistrationResponse, discovery: CmxIrohDiscoveryResponse, - bootstrap: CmxIrohRelayBootstrapResponse?, + bootstrap: CmxIrohRelayPolicyResponse?, suspendRelayBootstrap: Bool ) { self.registrationError = registrationError @@ -883,25 +869,11 @@ private actor MobileIrohCooldownBroker: throw MobileIrohCooldownTestError.unavailable } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - totalRequests += 1 - relayTokenRequests += 1 - guard let credential = bootstrap?.relayToken else { - throw MobileIrohCooldownTestError.unavailable - } - return credential - } - func revoke(bindingID _: String) { totalRequests += 1 } - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { + func fetchRelayPolicy() async throws -> CmxIrohRelayPolicyResponse { totalRequests += 1 bootstrapRequests += 1 let waiters = bootstrapRequestWaiters @@ -943,7 +915,6 @@ private actor MobileIrohCooldownBroker: func totalRequestCount() -> Int { totalRequests } func discoveryRequestCount() -> Int { discoveryRequests } func bootstrapRequestCount() -> Int { bootstrapRequests } - func relayTokenRequestCount() -> Int { relayTokenRequests } func waitForBootstrapRequest() async { guard bootstrapRequests == 0 else { return } @@ -987,8 +958,7 @@ private actor MobileIrohCooldownEndpoint: CmxIrohEndpoint { throw MobileIrohCooldownTestError.unavailable } - func accept() -> (any CmxIrohConnection)? { nil } - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} + func accept() -> (any CmxIrohIncomingConnection)? { nil } func healthEvents() -> AsyncStream { AsyncStream { $0.finish() } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift index 21faf770b166..164af9185a9b 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift @@ -1800,13 +1800,6 @@ private actor MobileIrohRevocationBroker: CmxIrohClientBrokerServing { throw MobileIrohSignOutTestError.unavailable } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - throw MobileIrohSignOutTestError.unavailable - } - func revoke(bindingID: String) { bindingIDs.append(bindingID) } @@ -1866,13 +1859,6 @@ private actor MobileIrohCredentialFetchingBroker: CmxIrohClientBrokerServing { throw MobileIrohSignOutTestError.unavailable } - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - throw MobileIrohSignOutTestError.unavailable - } - func revoke(bindingID: String) async throws { try await fetchCredentialPair() revoked.append(bindingID) diff --git a/scripts/mobile-dev-launch.sh b/scripts/mobile-dev-launch.sh index 5b8105d8ab5c..5dcfd0fbec31 100755 --- a/scripts/mobile-dev-launch.sh +++ b/scripts/mobile-dev-launch.sh @@ -392,7 +392,6 @@ if [[ "$TARGET" == "simulator" ]]; then SIMCTL_CHILD_CMUX_DOGFOOD_ATTACH_URL="$ATTACH_URL" \ SIMCTL_CHILD_CMUX_DOGFOOD_CLIENT_ID="$DOGFOOD_CLIENT_ID" \ SIMCTL_CHILD_CMUX_IROH_RELEASE_GATE_MODE="$IROH_RELEASE_GATE_MODE" \ - SIMCTL_CHILD_CMUX_IROH_RELEASE_GATE_SCENARIO="${CMUX_IROH_RELEASE_GATE_SCENARIO:-standard}" \ SIMCTL_CHILD_CMUX_IROH_DISABLE_RELAY_CREDENTIAL_REFRESH="${CMUX_IROH_DISABLE_RELAY_CREDENTIAL_REFRESH:-0}" \ xcrun simctl "${launch_args[@]}" "$SIM_UDID" "$BUNDLE_ID" else diff --git a/scripts/run-iroh-release-gate.sh b/scripts/run-iroh-release-gate.sh index e9c8e4c0f55a..f1cc3fdba0ee 100755 --- a/scripts/run-iroh-release-gate.sh +++ b/scripts/run-iroh-release-gate.sh @@ -3,13 +3,13 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: scripts/run-iroh-release-gate.sh --mode --tag +Usage: scripts/run-iroh-release-gate.sh --mode --tag [--staging-base-url ] [--presence-base-url ] [--skip-build] [--keep-simulator] [--report-output ] [--print-plan] [--production [--stack-env-file ]] -Automatic, relay-only, and relay-expiry build a tagged Mac app plus an isolated iOS Simulator +Automatic and relay-only build a tagged Mac app plus an isolated iOS Simulator app, sign both into the same staging account, pair only over Iroh, and verify the app RPC surface. Direct-only runs a deterministic two-Iroh-endpoint proof inside an isolated iOS Simulator with relays disabled. Private-path runs a @@ -78,11 +78,10 @@ if [[ "$PRODUCTION" -eq 1 ]]; then fi case "$MODE" in - automatic) RAW_MODE="automatic"; GATE_SCENARIO="standard"; GATE_PLAN="app-rpc" ;; - relay-only) RAW_MODE="relayOnly"; GATE_SCENARIO="relay_rollover"; GATE_PLAN="app-rpc" ;; - relay-expiry) RAW_MODE="relayOnly"; GATE_SCENARIO="relay_expiry"; GATE_PLAN="app-rpc" ;; - direct-only) RAW_MODE="directOnly"; GATE_SCENARIO="standard"; GATE_PLAN="simulator-direct-transport" ;; - private-path) RAW_MODE=""; GATE_SCENARIO="standard"; GATE_PLAN="host-private-path-transport" ;; + automatic) RAW_MODE="automatic"; GATE_PLAN="app-rpc" ;; + relay-only) RAW_MODE="relayOnly"; GATE_PLAN="app-rpc" ;; + direct-only) RAW_MODE="directOnly"; GATE_PLAN="simulator-direct-transport" ;; + private-path) RAW_MODE=""; GATE_PLAN="host-private-path-transport" ;; *) echo "error: invalid mode '$MODE'" >&2; exit 2 ;; esac @@ -663,8 +662,6 @@ if [[ "$PRODUCTION" -eq 1 ]]; then fi CMUX_ATTACH_MINT_MAX_ATTEMPTS=600 \ CMUX_ATTACH_READY_TIMEOUT_SECONDS="${CMUX_IROH_RELEASE_GATE_ATTACH_READY_TIMEOUT_SECONDS:-90}" \ -CMUX_IROH_RELEASE_GATE_SCENARIO="$GATE_SCENARIO" \ -CMUX_IROH_DISABLE_RELAY_CREDENTIAL_REFRESH="$([[ "$GATE_SCENARIO" == "relay_expiry" ]] && printf 1 || printf 0)" \ ./scripts/mobile-dev-launch.sh "${MOBILE_LAUNCH_ARGS[@]}" \ 2>&1 | sed -E \ -e 's/^(==> dev sign-in account:).*/\1 [redacted]/' \ @@ -699,7 +696,7 @@ if [[ -n "$REPORT_OUTPUT" ]]; then fi fi -REPORT_PATH="$REPORT_PATH" EXPECTED_MODE="$RAW_MODE" EXPECTED_SCENARIO="$GATE_SCENARIO" /usr/bin/python3 <<'PY' +REPORT_PATH="$REPORT_PATH" EXPECTED_MODE="$RAW_MODE" /usr/bin/python3 <<'PY' import json import os @@ -707,11 +704,9 @@ with open(os.environ["REPORT_PATH"], encoding="utf-8") as handle: report = json.load(handle) expected_mode = os.environ["EXPECTED_MODE"] -expected_scenario = os.environ["EXPECTED_SCENARIO"] allowed_keys = { "schemaVersion", "mode", - "scenario", "passed", "hostStatusVerified", "rpcMethodInventoryVerified", @@ -721,14 +716,6 @@ allowed_keys = { "notificationReconcileVerified", "chatSessionsVerified", "artifactScanCountVerified", - "relayCredentialRolloverVerified", - "endpointContinuityVerified", - "connectionContinuityVerified", - "controlStreamContinuityVerified", - "independentEventsContinuityVerified", - "artifactLaneVerified", - "unrefreshedExpiryDisconnectVerified", - "soakDurationSeconds", "routeKind", "selectedPath", "failure", @@ -755,12 +742,10 @@ problems = [] unexpected_keys = set(report) - allowed_keys if unexpected_keys: problems.append("report contained unexpected fields") -if report.get("schemaVersion") != 4: +if report.get("schemaVersion") != 5: problems.append("unexpected schemaVersion") if report.get("mode") != expected_mode: problems.append("mode mismatch") -if report.get("scenario") != expected_scenario: - problems.append("scenario mismatch") if report.get("routeKind") != "iroh": problems.append("route was not Iroh") if report.get("selectedPath") not in allowed_paths[expected_mode]: @@ -768,22 +753,6 @@ if report.get("selectedPath") not in allowed_paths[expected_mode]: for key in required_true: if report.get(key) is not True: problems.append(f"{key} was not true") -if expected_scenario == "relay_rollover": - for key in ( - "relayCredentialRolloverVerified", - "endpointContinuityVerified", - "connectionContinuityVerified", - "controlStreamContinuityVerified", - "independentEventsContinuityVerified", - "artifactLaneVerified", - ): - if report.get(key) is not True: - problems.append(f"{key} was not true") - if report.get("soakDurationSeconds", 0) < 330: - problems.append("rollover soak was shorter than 330 seconds") -elif expected_scenario == "relay_expiry": - if report.get("unrefreshedExpiryDisconnectVerified") is not True: - problems.append("unrefreshedExpiryDisconnectVerified was not true") redacted_report = {key: report.get(key) for key in sorted(allowed_keys) if key in report} print(json.dumps(redacted_report, sort_keys=True)) diff --git a/services/iroh-relay-minter/.env.example b/services/iroh-relay-minter/.env.example deleted file mode 100644 index 5f56d2143364..000000000000 --- a/services/iroh-relay-minter/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Configure these only on the isolated relay-minter Vercel project. -IROH_SERVICES_API_SECRET= -CMUX_IROH_MINT_HMAC_SECRET_B64= -# Optional, minter-only overlap key during a bounded HMAC rotation. -CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64= diff --git a/services/iroh-relay-minter/.gitignore b/services/iroh-relay-minter/.gitignore deleted file mode 100644 index 226a93ca6de2..000000000000 --- a/services/iroh-relay-minter/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/target/ -/.vercel/ -/.env* -!/.env.example diff --git a/services/iroh-relay-minter/Cargo.lock b/services/iroh-relay-minter/Cargo.lock deleted file mode 100644 index c31436a8045b..000000000000 --- a/services/iroh-relay-minter/Cargo.lock +++ /dev/null @@ -1,4590 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async_io_stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" -dependencies = [ - "futures", - "pharos", - "rustc_version", -] - -[[package]] -name = "atomic-polyfill" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "attohttpc" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" -dependencies = [ - "base64", - "http", - "log", - "url", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", -] - -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "blake3" -version = "1.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "built" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" -dependencies = [ - "cargo-lock", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo-lock" -version = "11.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63585cdf8572aa7adf0e30a253f988f2b77233bfac1973d52efb6dd53a75920e" -dependencies = [ - "semver", - "serde", - "toml", - "url", -] - -[[package]] -name = "cc" -version = "1.2.66" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "cmux-iroh-relay-minter" -version = "0.1.0" -dependencies = [ - "base64", - "data-encoding", - "futures-util", - "hex", - "hmac", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "iroh", - "iroh-services", - "rcan", - "serde", - "serde_json", - "sha2 0.10.9", - "time", - "tokio", - "vercel_runtime", - "zeroize", -] - -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.18", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cordyceps" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" -dependencies = [ - "loom", - "tracing", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0-rc.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto", - "rand_core", - "rustc_version", - "serde", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "data-encoding-macro" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" -dependencies = [ - "data-encoding", - "data-encoding-macro-internal", -] - -[[package]] -name = "data-encoding-macro-internal" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" -dependencies = [ - "data-encoding", - "syn", -] - -[[package]] -name = "der" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", -] - -[[package]] -name = "diatomic-waker" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "pkcs8", - "serdect", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0-rc.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core", - "serde", - "sha2 0.11.0", - "signature", - "subtle", - "zeroize", -] - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - -[[package]] -name = "enum-assoc" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-buffered" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" -dependencies = [ - "cordyceps", - "diatomic-waker", - "futures-core", - "pin-project-lite", - "spin 0.10.0", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generator" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link", - "windows-result", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core", - "wasm-bindgen", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hash32" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "heapless" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" -dependencies = [ - "atomic-polyfill", - "hash32", - "rustc_version", - "serde", - "spin 0.9.8", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hickory-net" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" -dependencies = [ - "async-trait", - "bytes", - "cfg-if", - "data-encoding", - "futures-channel", - "futures-io", - "futures-util", - "h2", - "hickory-proto", - "http", - "idna", - "ipnet", - "jni 0.22.4", - "rand", - "rustls", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tokio-rustls", - "tracing", - "url", -] - -[[package]] -name = "hickory-proto" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" -dependencies = [ - "data-encoding", - "idna", - "ipnet", - "jni 0.22.4", - "once_cell", - "prefix-trie", - "rand", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-net", - "hickory-proto", - "ipconfig", - "ipnet", - "jni 0.22.4", - "moka", - "ndk-context", - "once_cell", - "parking_lot", - "rand", - "resolv-conf", - "rustls", - "smallvec", - "system-configuration", - "thiserror 2.0.18", - "tokio", - "tokio-rustls", - "tracing", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-layer", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "identity-hash" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "igd-next" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" -dependencies = [ - "attohttpc", - "bytes", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-util", - "log", - "rand", - "tokio", - "url", - "xmltree", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2", - "widestring", - "windows-registry", - "windows-result", - "windows-sys 0.61.2", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -dependencies = [ - "serde", -] - -[[package]] -name = "iroh" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" -dependencies = [ - "backon", - "blake3", - "bytes", - "cfg_aliases", - "ctutils", - "data-encoding", - "derive_more", - "ed25519-dalek", - "futures-util", - "getrandom 0.4.3", - "hickory-resolver", - "http", - "ipnet", - "iroh-base", - "iroh-dns", - "iroh-metrics", - "iroh-relay", - "n0-error", - "n0-future", - "n0-watcher", - "netwatch", - "noq", - "noq-proto", - "noq-udp", - "papaya", - "pin-project", - "portable-atomic", - "portmapper", - "rand", - "reqwest", - "rustc-hash", - "rustls", - "rustls-pki-types", - "serde", - "smallvec", - "strum", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "wasm-bindgen-futures", -] - -[[package]] -name = "iroh-base" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" -dependencies = [ - "curve25519-dalek", - "data-encoding", - "data-encoding-macro", - "derive_more", - "ed25519-dalek", - "getrandom 0.4.3", - "n0-error", - "rand", - "serde", - "url", - "zeroize", -] - -[[package]] -name = "iroh-dns" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" -dependencies = [ - "arc-swap", - "cfg_aliases", - "derive_more", - "hickory-resolver", - "iroh-base", - "n0-error", - "n0-future", - "ndk-context", - "portable-atomic", - "rand", - "rustls", - "simple-dns", - "strum", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "iroh-metrics" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" -dependencies = [ - "iroh-metrics-derive", - "itoa", - "n0-error", - "portable-atomic", - "ryu", - "serde", - "tracing", -] - -[[package]] -name = "iroh-metrics-derive" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "iroh-relay" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" -dependencies = [ - "blake3", - "bytes", - "cfg_aliases", - "data-encoding", - "derive_more", - "getrandom 0.4.3", - "hickory-resolver", - "http", - "http-body-util", - "hyper", - "hyper-util", - "iroh-base", - "iroh-dns", - "iroh-metrics", - "lru", - "n0-error", - "n0-future", - "noq", - "noq-proto", - "num_enum", - "pin-project", - "postcard", - "rand", - "reqwest", - "rustls", - "rustls-pki-types", - "serde", - "serde_bytes", - "strum", - "tokio", - "tokio-rustls", - "tokio-util", - "tokio-websockets", - "tracing", - "url", - "vergen-gitcl", - "webpki-roots", - "ws_stream_wasm", -] - -[[package]] -name = "iroh-services" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a88cd95fbd20abd9eadc4df91c722915dc943412b964e915f35b0b0a46a1fd" -dependencies = [ - "anyhow", - "base64", - "built", - "bytes", - "data-encoding", - "derive_more", - "ed25519-dalek", - "futures-buffered", - "getrandom 0.4.3", - "iroh", - "iroh-metrics", - "iroh-tickets", - "irpc", - "irpc-iroh", - "n0-error", - "n0-future", - "portmapper", - "postcard", - "rand", - "rcan", - "serde", - "serde_json", - "strum", - "thiserror 2.0.18", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "iroh-tickets" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" -dependencies = [ - "data-encoding", - "derive_more", - "iroh-base", - "n0-error", - "postcard", - "serde", -] - -[[package]] -name = "irpc" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386" -dependencies = [ - "futures-buffered", - "futures-util", - "irpc-derive", - "n0-error", - "n0-future", - "noq", - "postcard", - "rcgen", - "rustls", - "serde", - "smallvec", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "irpc-derive" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "irpc-iroh" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2342daed629b312f61e57e452b0750a59da162f261b97f260a6354de61d4fb0e" -dependencies = [ - "getrandom 0.3.4", - "iroh", - "iroh-base", - "irpc", - "n0-error", - "n0-future", - "postcard", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" -dependencies = [ - "hashbrown", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac-addr" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "n0-error" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" -dependencies = [ - "n0-error-macros", - "spez", -] - -[[package]] -name = "n0-error-macros" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "n0-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" -dependencies = [ - "cfg_aliases", - "derive_more", - "futures-buffered", - "futures-lite", - "futures-util", - "js-sys", - "pin-project", - "send_wrapper", - "tokio", - "tokio-util", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "n0-watcher" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" -dependencies = [ - "derive_more", - "n0-error", - "n0-future", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "netdev" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" -dependencies = [ - "block2", - "dispatch2", - "dlopen2", - "ipnet", - "jni 0.21.1", - "libc", - "mac-addr", - "ndk-context", - "netlink-packet-core", - "netlink-packet-route", - "netlink-sys", - "objc2", - "objc2-core-foundation", - "objc2-core-wlan", - "objc2-foundation", - "objc2-system-configuration", - "once_cell", - "plist", - "windows-sys 0.61.2", -] - -[[package]] -name = "netlink-packet-core" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" -dependencies = [ - "paste", -] - -[[package]] -name = "netlink-packet-route" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" -dependencies = [ - "bitflags", - "libc", - "log", - "netlink-packet-core", -] - -[[package]] -name = "netlink-proto" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" -dependencies = [ - "bytes", - "futures", - "log", - "netlink-packet-core", - "netlink-sys", - "thiserror 2.0.18", -] - -[[package]] -name = "netlink-sys" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" -dependencies = [ - "bytes", - "futures-util", - "libc", - "log", - "tokio", -] - -[[package]] -name = "netwatch" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" -dependencies = [ - "atomic-waker", - "bytes", - "cfg_aliases", - "derive_more", - "ipnet", - "js-sys", - "libc", - "n0-error", - "n0-future", - "n0-watcher", - "netdev", - "netlink-packet-core", - "netlink-packet-route", - "netlink-proto", - "netlink-sys", - "noq-udp", - "objc2-core-foundation", - "objc2-system-configuration", - "pin-project-lite", - "serde", - "socket2", - "time", - "tokio", - "tokio-util", - "tracing", - "web-sys", - "windows", - "windows-result", - "wmi", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "noq" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" -dependencies = [ - "bytes", - "cfg_aliases", - "derive_more", - "noq-proto", - "noq-udp", - "pin-project-lite", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "web-time", -] - -[[package]] -name = "noq-proto" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" -dependencies = [ - "aes-gcm", - "bytes", - "derive_more", - "enum-assoc", - "getrandom 0.4.3", - "identity-hash", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "sorted-index-buffer", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "noq-udp" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" -dependencies = [ - "cfg_aliases", - "libc", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "libc", - "objc2", -] - -[[package]] -name = "objc2-core-wlan" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-security", - "objc2-security-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "bitflags", - "dispatch2", - "libc", - "objc2", - "objc2-core-foundation", - "objc2-security", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "papaya" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" -dependencies = [ - "equivalent", - "seize", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pharos" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" -dependencies = [ - "futures", - "rustc_version", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "plist" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" -dependencies = [ - "base64", - "indexmap", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -dependencies = [ - "serde", -] - -[[package]] -name = "portmapper" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" -dependencies = [ - "base64", - "bytes", - "derive_more", - "hyper-util", - "igd-next", - "iroh-metrics", - "libc", - "n0-error", - "n0-future", - "netwatch", - "num_enum", - "rand", - "serde", - "smallvec", - "socket2", - "time", - "tokio", - "tokio-util", - "tower-layer", - "tracing", - "url", -] - -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "heapless", - "postcard-derive", - "serde", -] - -[[package]] -name = "postcard-derive" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prefix-trie" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" -dependencies = [ - "either", - "ipnet", - "num-traits", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rcan" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a624a4a4742f8c6e58fba99712e606cea0491b76f5b2345f06af5802101027" -dependencies = [ - "anyhow", - "derive_more", - "ed25519-dalek", - "hex", - "n0-future", - "postcard", - "serde", - "serdect", -] - -[[package]] -name = "rcgen" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex-automata" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustls" -version = "0.23.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni 0.22.4", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "seize" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serdect" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" -dependencies = [ - "base16ct", - "serde", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "simple-dns" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" -dependencies = [ - "bitflags", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "sorted-index-buffer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" - -[[package]] -name = "spez" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spin" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" - -[[package]] -name = "spki" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" -dependencies = [ - "deranged", - "js-sys", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-websockets" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-sink", - "getrandom 0.4.3", - "http", - "httparse", - "rand", - "ring", - "rustls-pki-types", - "sha1_smol", - "simdutf8", - "tokio", - "tokio-rustls", - "tokio-util", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.3", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.3", -] - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vercel_runtime" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f5e7942f725023f1572b7fef91b0aeddc8749a3b9b0b191f59c208c42ed8e62" -dependencies = [ - "base64", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "lazy_static", - "libc", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "tower", -] - -[[package]] -name = "vergen" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "vergen-lib", -] - -[[package]] -name = "vergen-gitcl" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "time", - "vergen", - "vergen-lib", -] - -[[package]] -name = "vergen-lib" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - -[[package]] -name = "winnow" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wmi" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" -dependencies = [ - "chrono", - "futures", - "log", - "serde", - "thiserror 2.0.18", - "windows", - "windows-core", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "ws_stream_wasm" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" -dependencies = [ - "async_io_stream", - "futures", - "js-sys", - "log", - "pharos", - "rustc_version", - "send_wrapper", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - -[[package]] -name = "xmltree" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" -dependencies = [ - "xml-rs", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/services/iroh-relay-minter/Cargo.toml b/services/iroh-relay-minter/Cargo.toml deleted file mode 100644 index f50a71e4290d..000000000000 --- a/services/iroh-relay-minter/Cargo.toml +++ /dev/null @@ -1,53 +0,0 @@ -[package] -name = "cmux-iroh-relay-minter" -version = "0.1.0" -edition = "2024" -rust-version = "1.91" -publish = false -autobins = false - -[lib] -path = "src/lib.rs" - -[[bin]] -name = "relay-token" -path = "api/relay-token.rs" - -[dependencies] -base64 = "0.22.1" -data-encoding = "2.9.0" -hex = "0.4.3" -hmac = "0.12.1" -http-body = "1.0.1" -http-body-util = "0.1.3" -hyper = { version = "1.7.0", features = ["http1"] } -iroh = { version = "=1.0.0", default-features = false } -iroh-services = { version = "=1.0.0", default-features = false } -rcan = "=0.4.0" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.145" -sha2 = "0.10.9" -time = { version = "0.3.44", features = ["formatting", "parsing"] } -tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } -vercel_runtime = "=2.0.0" -zeroize = "1.8.1" - -[dev-dependencies] -futures-util = "0.3.31" -hyper-util = { version = "0.1.17", features = ["server", "http1", "tokio"] } -tokio = { version = "1.47.1", features = ["net"] } - -[profile.release] -codegen-units = 1 -lto = "fat" -opt-level = 3 -panic = "abort" -strip = true - -[lints.rust] -unsafe_code = "forbid" - -[lints.clippy] -dbg_macro = "deny" -todo = "deny" -unimplemented = "deny" diff --git a/services/iroh-relay-minter/README.md b/services/iroh-relay-minter/README.md deleted file mode 100644 index 9d9858cf0684..000000000000 --- a/services/iroh-relay-minter/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# Iroh relay-token minter - -This Vercel Rust project is the only cmux service allowed to hold the Iroh -Services project credential. It converts a short-lived request authenticated by -the cmux web trust broker into a 24-hour, endpoint-scoped RCAN containing only -`relay:use`. - -Deploy this directory as a separate Vercel project. Set its Root Directory to -`services/iroh-relay-minter`. Do not add `IROH_SERVICES_API_SECRET` to the cmux -web project because Vercel environment variables are project-wide. - -## Environment - -The minter project requires: - -- `IROH_SERVICES_API_SECRET`: the rotated Iroh Services project secret. It is - parsed by `iroh-services` 1.0.0 and is never returned or logged. -- `CMUX_IROH_MINT_HMAC_SECRET_B64`: 32 to 256 random bytes encoded as canonical - standard base64. Generate a new 32-byte value with `openssl rand -base64 32`. -- `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64`: optional minter-only previous key - accepted during a bounded rotation overlap. It must differ from the current - key. The web project never receives this value. - -The web project requires the same `CMUX_IROH_MINT_HMAC_SECRET_B64` value and: - -- `CMUX_IROH_MINT_URL=https:///api/relay-token` - -Rotate any Iroh Services credential previously pasted into chat or logs before -putting it in Vercel. A Services credential rotation affects only the minter. - -Rotate the HMAC without an outage in this order: - -1. Deploy the minter with the new key in `CMUX_IROH_MINT_HMAC_SECRET_B64` and - the old key in `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64`. -2. Change the web project's `CMUX_IROH_MINT_HMAC_SECRET_B64` to the new key. -3. Keep the previous key for at least five minutes, which covers the 30-second - request timestamp window and deployment propagation. -4. Remove `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64` from the minter. - -The overlap changes only which HMAC key authenticates the existing fixed -method, path, timestamp, and body-hash transcript. It does not expand the -minter route or RCAN capabilities. - -## Wire contract - -The only accepted route is `POST /api/relay-token` with one `Content-Type` -header whose media type is `application/json`, optionally followed by parameters -such as `charset=utf-8`, no query string, and this body: - -```json -{"endpointId":"<64 lowercase hex characters>","lifetimeSeconds":86400} -``` - -The web service sends: - -- `x-cmux-iroh-timestamp`: canonical Unix seconds, within 30 seconds of the - minter clock. -- `x-cmux-iroh-signature`: unpadded base64url HMAC-SHA256 over the transcript - below. - -```text -POST -/api/relay-token - - -``` - -The response is bounded JSON: - -```json -{"token":"","expiresAt":""} -``` - -The RCAN issuer is the Iroh Services project key, the audience is the supplied -EndpointID, the sole capability is `relay:use`, and expiry is 86,400 seconds. -The trust broker stores only issuance audit state and refreshes the relay token -after 12 hours. - -## Local verification - -No production secrets are needed for tests. - -```sh -cargo fmt --check -cargo clippy --all-targets --locked -- -D warnings -cargo test --locked -``` - -For authenticated local dogfood, the example server binds only to loopback and -uses the same request handler as the Vercel function: - -```sh -CMUX_IROH_MINT_DEV_PORT=9460 cargo run --locked --example loopback -``` - -It still requires `IROH_SERVICES_API_SECRET` and -`CMUX_IROH_MINT_HMAC_SECRET_B64` in the process environment. Do not use a -credential copied through chat for a deployed environment; rotate it first. diff --git a/services/iroh-relay-minter/api/relay-token.rs b/services/iroh-relay-minter/api/relay-token.rs deleted file mode 100644 index 1c1da8d8e52a..000000000000 --- a/services/iroh-relay-minter/api/relay-token.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::time::SystemTime; - -use cmux_iroh_relay_minter::{MinterConfig, configuration_error_response, handle_request}; -use vercel_runtime::{Error, Request, Response, ResponseBody, run, service_fn}; - -#[tokio::main] -async fn main() -> Result<(), Error> { - run(service_fn(handler)).await -} - -async fn handler(request: Request) -> Result, Error> { - let config = match MinterConfig::from_env() { - Ok(config) => config, - Err(_) => return Ok(configuration_error_response()), - }; - - Ok(handle_request(request, &config, SystemTime::now()).await) -} diff --git a/services/iroh-relay-minter/examples/loopback.rs b/services/iroh-relay-minter/examples/loopback.rs deleted file mode 100644 index 2d501715eb9d..000000000000 --- a/services/iroh-relay-minter/examples/loopback.rs +++ /dev/null @@ -1,37 +0,0 @@ -use std::{convert::Infallible, env, sync::Arc, time::SystemTime}; - -use cmux_iroh_relay_minter::{MinterConfig, handle_request}; -use hyper::{Request, body::Incoming, server::conn::http1, service::service_fn}; -use hyper_util::rt::TokioIo; -use tokio::net::TcpListener; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let port = env::var("CMUX_IROH_MINT_DEV_PORT") - .ok() - .map(|value| value.parse::()) - .transpose()? - .unwrap_or(9460); - let listener = TcpListener::bind(("127.0.0.1", port)).await?; - let config = Arc::new(MinterConfig::from_env()?); - eprintln!("Iroh relay minter listening on http://127.0.0.1:{port}"); - - loop { - let (stream, peer) = listener.accept().await?; - if !peer.ip().is_loopback() { - continue; - } - let config = Arc::clone(&config); - tokio::spawn(async move { - let service = service_fn(move |request: Request| { - let config = Arc::clone(&config); - async move { - Ok::<_, Infallible>(handle_request(request, &config, SystemTime::now()).await) - } - }); - let _ = http1::Builder::new() - .serve_connection(TokioIo::new(stream), service) - .await; - }); - } -} diff --git a/services/iroh-relay-minter/rust-toolchain.toml b/services/iroh-relay-minter/rust-toolchain.toml deleted file mode 100644 index 0f39414be778..000000000000 --- a/services/iroh-relay-minter/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "1.91.0" -profile = "minimal" -components = ["clippy", "rustfmt"] diff --git a/services/iroh-relay-minter/src/lib.rs b/services/iroh-relay-minter/src/lib.rs deleted file mode 100644 index 2672a171f1b0..000000000000 --- a/services/iroh-relay-minter/src/lib.rs +++ /dev/null @@ -1,929 +0,0 @@ -use std::{ - env, fmt, - str::FromStr, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -use base64::{ - Engine as _, - engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}, -}; -use data_encoding::BASE32_NOPAD; -use hmac::{Hmac, Mac}; -use http_body::Body; -use http_body_util::BodyExt as _; -use hyper::{ - Method, Request, Response, StatusCode, - body::Bytes, - header::{ALLOW, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderName}, -}; -use iroh::{EndpointId, SecretKey}; -use iroh_services::{ - ApiSecret, - caps::{Cap, Caps, RelayCap, create_api_token_from_secret_key}, -}; -use rcan::Expires; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use vercel_runtime::ResponseBody; -use zeroize::Zeroizing; - -pub const MINT_PATH: &str = "/api/relay-token"; -pub const RELAY_TOKEN_LIFETIME_SECONDS: u64 = 86_400; -pub const MAX_REQUEST_BYTES: usize = 4 * 1_024; - -const MAX_RESPONSE_BYTES: usize = 32 * 1_024; -const MAX_TOKEN_BYTES: usize = 16 * 1_024; -const CLOCK_SKEW_SECONDS: u64 = 30; -const SERVICES_SECRET_ENV: &str = "IROH_SERVICES_API_SECRET"; -const HMAC_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_SECRET_B64"; -const HMAC_PREVIOUS_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64"; -const TIMESTAMP_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-timestamp"); -const SIGNATURE_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-signature"); - -type HmacSha256 = Hmac; - -pub struct MinterConfig { - issuer: SecretKey, - hmac_secret: Zeroizing>, - hmac_previous_secret: Option>>, -} - -impl MinterConfig { - pub fn from_env() -> Result { - let services_secret = Zeroizing::new(bounded_env(SERVICES_SECRET_ENV, 16_384)?); - let api_secret = - ApiSecret::from_str(services_secret.as_str()).map_err(|_| ConfigurationError)?; - let hmac_secret_text = Zeroizing::new(bounded_env(HMAC_SECRET_ENV, 512)?); - let hmac_secret = Zeroizing::new(decode_hmac_secret(hmac_secret_text.as_str())?); - let hmac_previous_secret = optional_bounded_env(HMAC_PREVIOUS_SECRET_ENV, 512)? - .map(Zeroizing::new) - .map(|value| decode_hmac_secret(value.as_str())) - .transpose()? - .map(Zeroizing::new); - if hmac_previous_secret - .as_ref() - .is_some_and(|previous| previous.as_slice() == hmac_secret.as_slice()) - { - return Err(ConfigurationError); - } - - Ok(Self { - issuer: api_secret.secret, - hmac_secret, - hmac_previous_secret, - }) - } -} - -#[derive(Clone, Copy, Debug)] -pub struct ConfigurationError; - -impl fmt::Display for ConfigurationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("relay minter configuration is unavailable") - } -} - -impl std::error::Error for ConfigurationError {} - -pub async fn handle_request( - request: Request, - config: &MinterConfig, - now: SystemTime, -) -> Response -where - B: Body + Unpin, -{ - match process_request(request, config, now).await { - Ok(response) => json_response(StatusCode::OK, &response), - Err(error) => error_response(error.status(), error.code()), - } -} - -pub fn configuration_error_response() -> Response { - error_response(StatusCode::SERVICE_UNAVAILABLE, "configuration_unavailable") -} - -async fn process_request( - request: Request, - config: &MinterConfig, - now: SystemTime, -) -> Result -where - B: Body + Unpin, -{ - if request.method() != Method::POST { - return Err(RequestFailure::Method); - } - if request.uri().path() != MINT_PATH || request.uri().query().is_some() { - return Err(RequestFailure::Path); - } - require_json_content_type(request.headers())?; - validate_content_length(request.headers())?; - - let timestamp_text = single_header(request.headers(), &TIMESTAMP_HEADER) - .ok_or(RequestFailure::Authentication)? - .to_owned(); - let timestamp = parse_timestamp(×tamp_text).ok_or(RequestFailure::Authentication)?; - let now_seconds = unix_seconds(now).ok_or(RequestFailure::Internal)?; - if now_seconds.abs_diff(timestamp) > CLOCK_SKEW_SECONDS { - return Err(RequestFailure::Authentication); - } - - let signature_text = single_header(request.headers(), &SIGNATURE_HEADER) - .ok_or(RequestFailure::Authentication)?; - let signature = decode_signature(signature_text).ok_or(RequestFailure::Authentication)?; - - let body = read_bounded_body(request.into_body()).await?; - verify_configured_hmac(config, ×tamp_text, &body, &signature)?; - - let input: MintRequest = - serde_json::from_slice(&body).map_err(|_| RequestFailure::InvalidBody)?; - if input.lifetime_seconds != RELAY_TOKEN_LIFETIME_SECONDS { - return Err(RequestFailure::InvalidLifetime); - } - let endpoint_id = parse_endpoint_id(&input.endpoint_id)?; - - mint_token(config, endpoint_id, now_seconds) -} - -async fn read_bounded_body(mut body: B) -> Result, RequestFailure> -where - B: Body + Unpin, -{ - if body - .size_hint() - .upper() - .is_some_and(|upper| upper > MAX_REQUEST_BYTES as u64) - { - return Err(RequestFailure::BodyTooLarge); - } - - let mut bytes = Vec::with_capacity( - body.size_hint() - .upper() - .unwrap_or(0) - .min(MAX_REQUEST_BYTES as u64) as usize, - ); - while let Some(frame) = body.frame().await { - let frame = frame.map_err(|_| RequestFailure::InvalidBody)?; - let Ok(data) = frame.into_data() else { - continue; - }; - let Some(next_len) = bytes.len().checked_add(data.len()) else { - return Err(RequestFailure::BodyTooLarge); - }; - if next_len > MAX_REQUEST_BYTES { - return Err(RequestFailure::BodyTooLarge); - } - bytes.extend_from_slice(&data); - } - Ok(bytes) -} - -fn mint_token( - config: &MinterConfig, - endpoint_id: EndpointId, - authenticated_at: u64, -) -> Result { - let capability = Caps::new([Cap::Relay(RelayCap::Use)]); - let rcan = create_api_token_from_secret_key( - config.issuer.clone(), - endpoint_id, - Duration::from_secs(RELAY_TOKEN_LIFETIME_SECONDS), - capability, - ) - .map_err(|_| RequestFailure::Internal)?; - - let Expires::At(expires_at) = rcan.expires() else { - return Err(RequestFailure::Internal); - }; - let expected_expiry = authenticated_at - .checked_add(RELAY_TOKEN_LIFETIME_SECONDS) - .ok_or(RequestFailure::Internal)?; - if expected_expiry.abs_diff(*expires_at) > 2 { - return Err(RequestFailure::Internal); - } - - let mut token = BASE32_NOPAD.encode(&rcan.encode()); - token.make_ascii_lowercase(); - if token.is_empty() - || token.len() > MAX_TOKEN_BYTES - || token.contains('=') - || !token - .bytes() - .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) - { - return Err(RequestFailure::Internal); - } - - let expires_at_i64 = i64::try_from(*expires_at).map_err(|_| RequestFailure::Internal)?; - let expires_at = OffsetDateTime::from_unix_timestamp(expires_at_i64) - .map_err(|_| RequestFailure::Internal)? - .format(&Rfc3339) - .map_err(|_| RequestFailure::Internal)?; - if expires_at.len() > 64 { - return Err(RequestFailure::Internal); - } - - Ok(MintResponse { token, expires_at }) -} - -fn verify_hmac( - secret: &[u8], - timestamp: &str, - body: &[u8], - signature: &[u8; 32], -) -> Result<(), RequestFailure> { - let body_hash = hex::encode(Sha256::digest(body)); - let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); - let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| RequestFailure::Internal)?; - mac.update(transcript.as_bytes()); - mac.verify_slice(signature) - .map_err(|_| RequestFailure::Authentication) -} - -fn verify_configured_hmac( - config: &MinterConfig, - timestamp: &str, - body: &[u8], - signature: &[u8; 32], -) -> Result<(), RequestFailure> { - let current_matches = - verify_hmac(config.hmac_secret.as_slice(), timestamp, body, signature).is_ok(); - let previous_matches = config - .hmac_previous_secret - .as_ref() - .is_some_and(|secret| verify_hmac(secret.as_slice(), timestamp, body, signature).is_ok()); - if current_matches || previous_matches { - Ok(()) - } else { - Err(RequestFailure::Authentication) - } -} - -fn parse_endpoint_id(value: &str) -> Result { - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(RequestFailure::InvalidEndpoint); - } - let mut bytes = [0_u8; 32]; - hex::decode_to_slice(value, &mut bytes).map_err(|_| RequestFailure::InvalidEndpoint)?; - EndpointId::from_bytes(&bytes).map_err(|_| RequestFailure::InvalidEndpoint) -} - -fn require_json_content_type(headers: &HeaderMap) -> Result<(), RequestFailure> { - let content_type = single_header(headers, &CONTENT_TYPE).ok_or(RequestFailure::ContentType)?; - let media_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - if media_type.eq_ignore_ascii_case("application/json") { - Ok(()) - } else { - Err(RequestFailure::ContentType) - } -} - -fn validate_content_length(headers: &HeaderMap) -> Result<(), RequestFailure> { - let values = headers.get_all(&CONTENT_LENGTH); - let mut values = values.iter(); - let Some(value) = values.next() else { - return Ok(()); - }; - if values.next().is_some() { - return Err(RequestFailure::InvalidBody); - } - let length = value - .to_str() - .ok() - .and_then(|value| value.parse::().ok()) - .ok_or(RequestFailure::InvalidBody)?; - if length > MAX_REQUEST_BYTES { - Err(RequestFailure::BodyTooLarge) - } else { - Ok(()) - } -} - -fn single_header<'a>(headers: &'a HeaderMap, name: &HeaderName) -> Option<&'a str> { - let values = headers.get_all(name); - let mut values = values.iter(); - let value = values.next()?.to_str().ok()?; - if values.next().is_some() { - return None; - } - Some(value) -} - -fn parse_timestamp(value: &str) -> Option { - if value.is_empty() || value.len() > 20 || !value.bytes().all(|byte| byte.is_ascii_digit()) { - return None; - } - let parsed: u64 = value.parse().ok()?; - (parsed.to_string() == value).then_some(parsed) -} - -fn decode_signature(value: &str) -> Option<[u8; 32]> { - if value.len() != 43 || value.contains('=') { - return None; - } - let decoded = URL_SAFE_NO_PAD.decode(value.as_bytes()).ok()?; - let signature: [u8; 32] = decoded.try_into().ok()?; - (URL_SAFE_NO_PAD.encode(signature) == value).then_some(signature) -} - -fn decode_hmac_secret(value: &str) -> Result, ConfigurationError> { - let decoded = STANDARD - .decode(value.as_bytes()) - .or_else(|_| STANDARD_NO_PAD.decode(value.as_bytes())) - .map_err(|_| ConfigurationError)?; - if decoded.len() < 32 || decoded.len() > 256 { - return Err(ConfigurationError); - } - let canonical_padded = STANDARD.encode(&decoded); - let canonical_unpadded = STANDARD_NO_PAD.encode(&decoded); - if value != canonical_padded && value != canonical_unpadded { - return Err(ConfigurationError); - } - Ok(decoded) -} - -fn bounded_env(name: &str, max_bytes: usize) -> Result { - let value = env::var(name).map_err(|_| ConfigurationError)?; - if value.is_empty() || value.len() > max_bytes { - return Err(ConfigurationError); - } - Ok(value) -} - -fn optional_bounded_env( - name: &str, - max_bytes: usize, -) -> Result, ConfigurationError> { - match env::var(name) { - Ok(value) if !value.is_empty() && value.len() <= max_bytes => Ok(Some(value)), - Ok(_) => Err(ConfigurationError), - Err(env::VarError::NotPresent) => Ok(None), - Err(env::VarError::NotUnicode(_)) => Err(ConfigurationError), - } -} - -fn unix_seconds(time: SystemTime) -> Option { - time.duration_since(UNIX_EPOCH) - .ok() - .map(|value| value.as_secs()) -} - -fn json_response(status: StatusCode, value: &impl Serialize) -> Response { - let body = serde_json::to_string(value) - .unwrap_or_else(|_| "{\"error\":\"internal_error\"}".to_owned()); - if body.len() > MAX_RESPONSE_BYTES { - return error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal_error"); - } - response(status, body) -} - -fn error_response(status: StatusCode, code: &'static str) -> Response { - let body = serde_json::to_string(&ErrorResponse { error: code }) - .unwrap_or_else(|_| "{\"error\":\"internal_error\"}".to_owned()); - let mut response = response(status, body); - if status == StatusCode::METHOD_NOT_ALLOWED { - response - .headers_mut() - .insert(ALLOW, "POST".parse().expect("static header value")); - } - response -} - -fn response(status: StatusCode, body: String) -> Response { - Response::builder() - .status(status) - .header(CONTENT_TYPE, "application/json") - .header(CACHE_CONTROL, "no-store") - .header("x-content-type-options", "nosniff") - .body(ResponseBody::from(body)) - .expect("static response metadata is valid") -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct MintRequest { - endpoint_id: String, - lifetime_seconds: u64, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct MintResponse { - token: String, - expires_at: String, -} - -#[derive(Serialize)] -struct ErrorResponse { - error: &'static str, -} - -#[derive(Clone, Copy, Debug)] -enum RequestFailure { - Method, - Path, - ContentType, - Authentication, - BodyTooLarge, - InvalidBody, - InvalidEndpoint, - InvalidLifetime, - Internal, -} - -impl RequestFailure { - const fn status(self) -> StatusCode { - match self { - Self::Method => StatusCode::METHOD_NOT_ALLOWED, - Self::Path => StatusCode::NOT_FOUND, - Self::ContentType => StatusCode::UNSUPPORTED_MEDIA_TYPE, - Self::Authentication => StatusCode::UNAUTHORIZED, - Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - Self::InvalidBody | Self::InvalidEndpoint | Self::InvalidLifetime => { - StatusCode::BAD_REQUEST - } - Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - const fn code(self) -> &'static str { - match self { - Self::Method => "method_not_allowed", - Self::Path => "not_found", - Self::ContentType => "unsupported_media_type", - Self::Authentication => "unauthorized", - Self::BodyTooLarge => "body_too_large", - Self::InvalidBody => "invalid_body", - Self::InvalidEndpoint => "invalid_endpoint_id", - Self::InvalidLifetime => "invalid_lifetime", - Self::Internal => "internal_error", - } - } -} - -#[cfg(test)] -mod tests { - use std::{convert::Infallible, time::SystemTime}; - - use futures_util::stream; - use http_body::Frame; - use http_body_util::{BodyExt as _, Full, StreamBody}; - use hyper::{ - Method, Request, StatusCode, - body::Bytes, - header::{ALLOW, HeaderValue}, - }; - use iroh::SecretKey; - use rcan::{CapabilityOrigin, Expires, Rcan}; - use time::OffsetDateTime; - - use super::*; - - const TEST_HMAC_SECRET: [u8; 32] = [0x42; 32]; - const PREVIOUS_HMAC_SECRET: [u8; 32] = [0x41; 32]; - - #[tokio::test] - async fn mints_lowercase_relay_only_rcan_for_the_exact_audience() { - let config = test_config(); - let endpoint = test_endpoint(); - let now = SystemTime::now(); - let now_seconds = unix_seconds(now).expect("test clock is after the Unix epoch"); - let body = valid_body(&endpoint.to_string()); - let request = signed_request(Method::POST, MINT_PATH, now_seconds, body); - - let response = handle_request(request, &config, now).await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); - let response: MintResponse = response_json(response).await; - - assert!(!response.token.contains('=')); - assert!(response.token.len() <= MAX_TOKEN_BYTES); - assert!( - response - .token - .bytes() - .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) - ); - - let token_bytes = BASE32_NOPAD - .decode(response.token.to_ascii_uppercase().as_bytes()) - .expect("response is unpadded base32"); - let rcan = Rcan::::decode(&token_bytes).expect("response is a signed RCAN"); - assert_eq!(rcan.audience(), &endpoint.as_verifying_key()); - assert_eq!(rcan.issuer(), &config.issuer.public().as_verifying_key()); - assert_eq!(rcan.capability_origin(), &CapabilityOrigin::Issuer); - assert_eq!(rcan.capability().to_strings(), ["relay:use"]); - - let Expires::At(token_expiry) = rcan.expires() else { - panic!("relay token must expire"); - }; - assert!( - now_seconds - .checked_add(RELAY_TOKEN_LIFETIME_SECONDS) - .expect("test expiry is representable") - .abs_diff(*token_expiry) - <= 2 - ); - let response_expiry = OffsetDateTime::parse(&response.expires_at, &Rfc3339) - .expect("response expiry is RFC 3339"); - assert_eq!(response_expiry.unix_timestamp(), *token_expiry as i64); - } - - #[tokio::test] - async fn rejects_every_method_and_path_except_the_single_post_route() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - - let response = handle_request( - signed_request(Method::GET, MINT_PATH, timestamp, body.clone()), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!(response.headers()[ALLOW], "POST"); - - for path in ["/", "/api/relay-token/", "/api/relay-token?debug=1"] { - let response = handle_request( - signed_request(Method::POST, path, timestamp, body.clone()), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::NOT_FOUND, "path {path}"); - } - } - - #[tokio::test] - async fn rejects_missing_wrong_or_body_substituted_hmac() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - - let mut missing = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); - missing.headers_mut().remove(&SIGNATURE_HEADER); - assert_eq!( - handle_request(missing, &config, now).await.status(), - StatusCode::UNAUTHORIZED - ); - - let mut wrong = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); - wrong.headers_mut().insert( - &SIGNATURE_HEADER, - URL_SAFE_NO_PAD - .encode([0_u8; 32]) - .parse() - .expect("test header is valid"), - ); - assert_eq!( - handle_request(wrong, &config, now).await.status(), - StatusCode::UNAUTHORIZED - ); - - let mut substituted = signed_request(Method::POST, MINT_PATH, timestamp, body); - *substituted.body_mut() = Full::new(Bytes::from(valid_body( - &SecretKey::from_bytes(&[0x33; 32]).public().to_string(), - ))); - assert_eq!( - handle_request(substituted, &config, now).await.status(), - StatusCode::UNAUTHORIZED - ); - } - - #[tokio::test] - async fn accepts_the_previous_hmac_secret_only_during_rotation_overlap() { - let mut config = test_config(); - config.hmac_previous_secret = Some(Zeroizing::new(PREVIOUS_HMAC_SECRET.to_vec())); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - let request = signed_request_with_secret( - Method::POST, - MINT_PATH, - timestamp, - body.clone(), - &PREVIOUS_HMAC_SECRET, - ); - - assert_eq!( - handle_request(request, &config, now).await.status(), - StatusCode::OK - ); - let previous_after_overlap = signed_request_with_secret( - Method::POST, - MINT_PATH, - timestamp, - body, - &PREVIOUS_HMAC_SECRET, - ); - assert_eq!( - handle_request(previous_after_overlap, &test_config(), now) - .await - .status(), - StatusCode::UNAUTHORIZED - ); - } - - #[tokio::test] - async fn enforces_the_thirty_second_timestamp_window() { - let config = test_config(); - let now = SystemTime::now(); - let now_seconds = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - - for timestamp in [now_seconds - 31, now_seconds + 31] { - let response = handle_request( - signed_request(Method::POST, MINT_PATH, timestamp, body.clone()), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - for timestamp in [now_seconds - 30, now_seconds + 30] { - let response = handle_request( - signed_request(Method::POST, MINT_PATH, timestamp, body.clone()), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::OK); - } - - let mut malformed = signed_request(Method::POST, MINT_PATH, now_seconds, body); - malformed.headers_mut().insert( - &TIMESTAMP_HEADER, - "+123".parse().expect("test header is valid"), - ); - assert_eq!( - handle_request(malformed, &config, now).await.status(), - StatusCode::UNAUTHORIZED - ); - assert_eq!(parse_timestamp("01"), None); - } - - #[tokio::test] - async fn bounds_declared_and_streamed_request_bodies() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - - let mut declared = signed_request(Method::POST, MINT_PATH, timestamp, body); - declared.headers_mut().insert( - CONTENT_LENGTH, - (MAX_REQUEST_BYTES + 1) - .to_string() - .parse() - .expect("test header is valid"), - ); - assert_eq!( - handle_request(declared, &config, now).await.status(), - StatusCode::PAYLOAD_TOO_LARGE - ); - - let chunk = Bytes::from(vec![b'x'; MAX_REQUEST_BYTES / 2 + 1]); - let streamed_body = [chunk.clone(), chunk]; - let joined = streamed_body.concat(); - let signature = sign_request(timestamp, &joined); - let body_stream = StreamBody::new(stream::iter( - streamed_body - .into_iter() - .map(|chunk| Ok::, Infallible>(Frame::data(chunk))), - )); - let streamed = Request::builder() - .method(Method::POST) - .uri(MINT_PATH) - .header(CONTENT_TYPE, "application/json") - .header(&TIMESTAMP_HEADER, timestamp.to_string()) - .header(&SIGNATURE_HEADER, signature) - .body(body_stream) - .expect("test request is valid"); - assert_eq!( - handle_request(streamed, &config, now).await.status(), - StatusCode::PAYLOAD_TOO_LARGE - ); - } - - #[tokio::test] - async fn rejects_invalid_endpoint_lifetime_and_json_shape() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let endpoint = test_endpoint().to_string(); - let invalid_bodies = [ - format!( - "{{\"endpointId\":\"{}\",\"lifetimeSeconds\":86400}}", - endpoint.to_ascii_uppercase() - ), - format!("{{\"endpointId\":\"{endpoint}\",\"lifetimeSeconds\":86401}}"), - format!( - "{{\"endpointId\":\"{endpoint}\",\"lifetimeSeconds\":86400,\"capability\":\"all\"}}" - ), - "{}".to_owned(), - ]; - - for body in invalid_bodies { - let response = handle_request( - signed_request(Method::POST, MINT_PATH, timestamp, body), - &config, - now, - ) - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - } - - #[tokio::test] - async fn accepts_json_content_type_parameters() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - let mut request = signed_request(Method::POST, MINT_PATH, timestamp, body); - request.headers_mut().insert( - CONTENT_TYPE, - "Application/JSON; charset=utf-8" - .parse() - .expect("valid test header"), - ); - - assert_eq!( - handle_request(request, &config, now).await.status(), - StatusCode::OK - ); - } - - #[tokio::test] - async fn rejects_non_json_duplicate_and_malformed_content_type_headers() { - let config = test_config(); - let now = SystemTime::now(); - let timestamp = unix_seconds(now).expect("test clock is valid"); - let body = valid_body(&test_endpoint().to_string()); - let mut non_json = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); - non_json.headers_mut().insert( - CONTENT_TYPE, - "text/plain".parse().expect("valid test header"), - ); - let mut duplicate = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); - duplicate.headers_mut().append( - CONTENT_TYPE, - "application/json; charset=utf-8" - .parse() - .expect("valid test header"), - ); - let mut malformed = signed_request(Method::POST, MINT_PATH, timestamp, body); - malformed.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_bytes(&[0xff]).expect("opaque header bytes are representable"), - ); - - for request in [non_json, duplicate, malformed] { - assert_eq!( - handle_request(request, &config, now).await.status(), - StatusCode::UNSUPPORTED_MEDIA_TYPE - ); - } - } - - #[test] - fn matches_the_typescript_hmac_wire_fixture() { - #[derive(Deserialize)] - struct Fixture { - path: String, - timestamp: String, - body: String, - signature: String, - } - - let fixture: Fixture = serde_json::from_str(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../tests/fixtures/iroh/relay-minter-request-v1.json" - ))) - .expect("shared relay-minter fixture is valid"); - assert_eq!(fixture.path, MINT_PATH); - let timestamp = parse_timestamp(&fixture.timestamp).expect("fixture timestamp is valid"); - assert_eq!( - sign_request(timestamp, fixture.body.as_bytes()), - fixture.signature - ); - let signature = decode_signature(&fixture.signature).expect("fixture signature is valid"); - verify_hmac( - &TEST_HMAC_SECRET, - &fixture.timestamp, - fixture.body.as_bytes(), - &signature, - ) - .expect("fixture authenticates"); - let request: MintRequest = - serde_json::from_str(&fixture.body).expect("fixture body matches the contract"); - assert_eq!(request.lifetime_seconds, RELAY_TOKEN_LIFETIME_SECONDS); - parse_endpoint_id(&request.endpoint_id).expect("fixture endpoint is valid"); - } - - #[test] - fn accepts_only_canonical_hmac_secret_encodings_of_at_least_32_bytes() { - let padded = STANDARD.encode(TEST_HMAC_SECRET); - let unpadded = STANDARD_NO_PAD.encode(TEST_HMAC_SECRET); - assert_eq!( - decode_hmac_secret(&padded).expect("padded secret"), - TEST_HMAC_SECRET - ); - assert_eq!( - decode_hmac_secret(&unpadded).expect("unpadded secret"), - TEST_HMAC_SECRET - ); - assert!(decode_hmac_secret(&STANDARD.encode([1_u8; 31])).is_err()); - assert!(decode_hmac_secret(&format!(" {padded}")).is_err()); - assert!(decode_hmac_secret(&URL_SAFE_NO_PAD.encode([0xff_u8; 32])).is_err()); - } - - fn test_config() -> MinterConfig { - MinterConfig { - issuer: SecretKey::from_bytes(&[0x11; 32]), - hmac_secret: Zeroizing::new(TEST_HMAC_SECRET.to_vec()), - hmac_previous_secret: None, - } - } - - fn test_endpoint() -> EndpointId { - SecretKey::from_bytes(&[0x22; 32]).public() - } - - fn valid_body(endpoint_id: &str) -> String { - format!( - "{{\"endpointId\":\"{endpoint_id}\",\"lifetimeSeconds\":{RELAY_TOKEN_LIFETIME_SECONDS}}}" - ) - } - - fn signed_request( - method: Method, - path: &str, - timestamp: u64, - body: String, - ) -> Request> { - signed_request_with_secret(method, path, timestamp, body, &TEST_HMAC_SECRET) - } - - fn signed_request_with_secret( - method: Method, - path: &str, - timestamp: u64, - body: String, - secret: &[u8], - ) -> Request> { - Request::builder() - .method(method) - .uri(path) - .header(CONTENT_TYPE, "application/json") - .header(&TIMESTAMP_HEADER, timestamp.to_string()) - .header( - &SIGNATURE_HEADER, - sign_request_with_secret(secret, timestamp, body.as_bytes()), - ) - .body(Full::new(Bytes::from(body))) - .expect("test request is valid") - } - - fn sign_request(timestamp: u64, body: &[u8]) -> String { - sign_request_with_secret(&TEST_HMAC_SECRET, timestamp, body) - } - - fn sign_request_with_secret(secret: &[u8], timestamp: u64, body: &[u8]) -> String { - let body_hash = hex::encode(Sha256::digest(body)); - let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); - let mut mac = HmacSha256::new_from_slice(secret).expect("test HMAC key length is valid"); - mac.update(transcript.as_bytes()); - URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) - } - - async fn response_json(response: Response) -> T - where - T: for<'de> Deserialize<'de>, - { - let bytes = response - .into_body() - .collect() - .await - .expect("test response body is readable") - .to_bytes(); - serde_json::from_slice(&bytes).expect("test response is JSON") - } -} diff --git a/services/iroh-relay-minter/vercel.json b/services/iroh-relay-minter/vercel.json deleted file mode 100644 index 20fc77e7e7cf..000000000000 --- a/services/iroh-relay-minter/vercel.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json" -} diff --git a/tests/fixtures/iroh/relay-minter-request-v1.json b/tests/fixtures/iroh/relay-minter-request-v1.json deleted file mode 100644 index 978434ba6659..000000000000 --- a/tests/fixtures/iroh/relay-minter-request-v1.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "path": "/api/relay-token", - "timestamp": "1783641600", - "body": "{\"endpointId\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"lifetimeSeconds\":86400}", - "signature": "U7P9cSbpQMrtmshYTTuBALTsDhGwxWqTG4mIdlqgX4c" -} diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index c4f24eb1f3b7..7be44a94044f 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -73,18 +73,36 @@ func isTokenExpired(_ accessToken: String?) -> Bool { return payload.expiresInMillis <= 0 } +/// Refresh this long before the token's real expiry, so callers never hold a +/// token that dies mid-request. Clamped to half the token's total lifetime so +/// short-lived tokens (e.g. a 90s TTL) are not refreshed on every request. +let tokenRefreshMarginSeconds: TimeInterval = 300 + /// Check if token should NOT be refreshed (is "fresh enough"). -/// Returns TRUE if token expires in > 20 seconds AND was issued < 75 seconds ago. -func isTokenFreshEnough(_ accessToken: String?) -> Bool { +/// +/// Fresh means more than `tokenRefreshMarginSeconds` remain before the `exp` +/// claim (clamped to half the token's `exp - iat` lifetime, floored at 20s). +/// Refresh schedules off the token's REAL expiry: an earlier issued-age +/// heuristic ("issued < 75s ago") forced a network refresh every ~75s of +/// token age forever for any caller that requests tokens periodically, even +/// while the token was valid for a full hour (cmux#10897). +/// +/// `now` is injected for deterministic tests; production callers use the +/// default wall clock. +func isTokenFreshEnough(_ accessToken: String?, now: Date = Date()) -> Bool { guard let token = accessToken, let payload = decodeJWTPayload(token) else { return false // Can't decode, should refresh } - - let expiresInMoreThan20s = payload.expiresInMillis > 20_000 - let issuedLessThan75sAgo = payload.issuedMillisAgo < 75_000 - - return expiresInMoreThan20s && issuedLessThan75sAgo + guard let exp = payload.exp else { + return true // No expiry claim: nothing to refresh against + } + var margin = tokenRefreshMarginSeconds + if let iat = payload.iat, exp > iat { + margin = min(margin, (exp - iat) / 2) + } + margin = max(margin, 20) + return exp - now.timeIntervalSince1970 > margin } // MARK: - Refresh Lock Manager @@ -475,9 +493,10 @@ actor APIClient { // Network/server hiccup. PRESERVE the refresh token so a retry // after recovery succeeds, and never silently sign the user out. // Return the original access token only if it is still usable; - // a proactive refresh fires every 75s of token age (see - // `isTokenFreshEnough`) while the token is valid for ~1h, so the - // common transient-failure case still has a good token. When the + // a proactive refresh fires `tokenRefreshMarginSeconds` before + // the real expiry (see `isTokenFreshEnough`) while the token is + // valid for ~1h, so the common transient-failure case still has + // a good token. When the // token is genuinely expired, return nil access + non-nil refresh // so the caller can classify "recoverable" without decoding a JWT. let usableAccessToken = isTokenExpired(originalAccessToken) ? nil : originalAccessToken diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/TokenRefreshTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/TokenRefreshTests.swift index 2010eb4439e3..ad9935bfe067 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/TokenRefreshTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/TokenRefreshTests.swift @@ -191,7 +191,79 @@ struct TokenRefreshAlgorithmTests { func invalidTokenIsNotFresh() { #expect(isTokenFreshEnough("not-a-jwt") == false) } - + + // MARK: - Expiry-Scheduled Freshness (cmux#10897) + + private func jwt(iat: Int?, exp: Int?) -> String { + var claims: [String] = ["\"sub\":\"test\""] + if let iat { claims.append("\"iat\":\(iat)") } + if let exp { claims.append("\"exp\":\(exp)") } + let payloadJson = "{\(claims.joined(separator: ","))}" + let payloadBase64 = Data(payloadJson.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\(payloadBase64).signature" + } + + /// Regression for the ~78s steady-state refresh loop: a 1h token whose + /// issued-age exceeded the old 75s heuristic must stay fresh while it is + /// still far from its real expiry. All times are fixed and the clock is + /// injected, so the test is deterministic. + @Test("1h token older than 75s stays fresh until near real expiry") + func hourTokenOlderThan75sStaysFresh() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + // Issued 100s ago (>75s), expires in 3500s: fresh under expiry + // scheduling, stale under the removed issued-age heuristic. + let token = jwt(iat: epoch - 100, exp: epoch + 3500) + #expect(isTokenFreshEnough(token, now: now) == true) + } + + @Test("1h token refreshes inside the pre-expiry margin") + func hourTokenRefreshesInsideMargin() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + // 299s remain on a 3600s-lifetime token: inside the 300s margin. + let token = jwt(iat: epoch - 3301, exp: epoch + 299) + #expect(isTokenFreshEnough(token, now: now) == false) + // 301s remain: just outside the margin. + let fresh = jwt(iat: epoch - 3299, exp: epoch + 301) + #expect(isTokenFreshEnough(fresh, now: now) == true) + } + + @Test("Short-lived token margin clamps to half its lifetime") + func shortLivedTokenMarginClampsToHalfLifetime() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + // 90s lifetime clamps the margin to 45s: 50s remaining is fresh, + // 40s remaining is not. A fixed 300s margin would refresh a 90s + // token on every request. + let fresh = jwt(iat: epoch - 40, exp: epoch + 50) + #expect(isTokenFreshEnough(fresh, now: now) == true) + let stale = jwt(iat: epoch - 50, exp: epoch + 40) + #expect(isTokenFreshEnough(stale, now: now) == false) + } + + @Test("Margin floors at 20s for very short lifetimes") + func marginFloorsAt20Seconds() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + // 30s lifetime: half-lifetime would be 15s, floor keeps it at 20s. + let stale = jwt(iat: epoch - 12, exp: epoch + 18) + #expect(isTokenFreshEnough(stale, now: now) == false) + let fresh = jwt(iat: epoch - 9, exp: epoch + 21) + #expect(isTokenFreshEnough(fresh, now: now) == true) + } + + @Test("Token without exp claim never triggers a refresh") + func tokenWithoutExpIsAlwaysFresh() { + let epoch = 1_800_000_000 + let now = Date(timeIntervalSince1970: TimeInterval(epoch)) + let token = jwt(iat: epoch - 100_000, exp: nil) + #expect(isTokenFreshEnough(token, now: now) == true) + } + // MARK: - Compare And Set Tests @Test("Should update tokens when refresh token matches") diff --git a/web/.env.example b/web/.env.example index 7a73d45a897d..8480b50e457b 100644 --- a/web/.env.example +++ b/web/.env.example @@ -6,10 +6,10 @@ CMUX_CLIENT_CONFIG_RATE_LIMIT_ID= # Deployed requests fail closed when this and CMUX_FEEDBACK_RATE_LIMIT_ID are empty. CMUX_APP_SESSION_HANDOFF_RATE_LIMIT_ID= -# Iroh relay access token and signed relay policy. The catalog is the complete -# managed fleet and must use an increasing sequence whenever its contents change. +# Signed Iroh relay policy. The catalog is the complete managed fleet and must +# use an increasing sequence whenever its contents change. Relay admission is +# the relay's allow hook (/api/relay/allow); no client credentials are minted. # Custom relay credentials remain device-local and are never configured here. -CMUX_RELAY_JWT_PRIVATE_KEY_PEM= CMUX_RELAY_POLICY_KEY_ID= CMUX_RELAY_POLICY_PRIVATE_KEY_PEM= CMUX_RELAY_TOKEN_RATE_LIMIT_ID= @@ -78,7 +78,7 @@ NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= STACK_SECRET_SERVER_KEY= # Personal-account Iroh trust broker. The Iroh Services project API key is not -# accepted by this process; it belongs only in the isolated Rust relay minter. +# accepted by this process. # Grant verification JSON maps the current and previous KIDs to Ed25519 SPKI # PEM strings. The developer binding override remains off unless all three # override settings explicitly match the authenticated user and deployment. @@ -93,10 +93,6 @@ CMUX_IROH_GRANT_SIGNING_KID= # Versioned public Ed25519 key set. Each key is canonical SPKI DER base64. # {"version":1,"current_kid":"current","keys":[{"kid":"current","alg":"EdDSA","spki_der_base64":"..."}]} CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON= -CMUX_IROH_MINT_URL= -# Current request-authentication key. The optional previous overlap key belongs -# only in the isolated minter project. -CMUX_IROH_MINT_HMAC_SECRET_B64= CMUX_IROH_RATE_LIMIT_ID= # Must exactly match the presence Worker's CONNECTIVITY_INVALIDATION_SECRET. # Generate an independent value with `openssl rand -hex 32`. diff --git a/web/app/api/devices/iroh/relay-token/route.ts b/web/app/api/devices/iroh/relay-token/route.ts deleted file mode 100644 index c7dd0ed2899f..000000000000 --- a/web/app/api/devices/iroh/relay-token/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; - - -export async function POST(request: Request): Promise { - return handleIrohRoute(request, "relay_token"); -} diff --git a/web/app/api/relay/allow/route.ts b/web/app/api/relay/allow/route.ts index f8e8e0df2638..b8a9595f18ce 100644 --- a/web/app/api/relay/allow/route.ts +++ b/web/app/api/relay/allow/route.ts @@ -13,7 +13,7 @@ // availability through its allow cache. import { env } from "../../../env"; -import { jsonResponse } from "../../../../services/relay/http"; +import { jsonResponse, readBoundedBody } from "../../../../services/relay/http"; import { RELAY_ALLOW_SIGNATURE_HEADER, parseRelayAllowSecret, @@ -61,10 +61,10 @@ export async function handleRelayAllowRequest( const secret = parseRelayAllowSecret(deps.secretBase64()); if (!secret) return jsonResponse({ error: "relay_allow_not_configured" }, 503); - const body = await readBoundedBody( - request, - deps.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS, - ); + const body = await readBoundedBody(request, { + maxBytes: MAX_BODY_BYTES, + timeoutMs: deps.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS, + }); if (!body.ok) return body.response; const provided = providedSignature(request); @@ -174,59 +174,6 @@ function admissionResponse(admission: RelayAllowAdmission): Response { }); } -async function readBoundedBody( - request: Request, - timeoutMs: number, -): Promise< - | { readonly ok: true; readonly bytes: Uint8Array } - | { readonly ok: false; readonly response: Response } -> { - const contentLength = request.headers.get("content-length"); - if (contentLength) { - const parsed = Number(contentLength); - if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_BODY_BYTES) { - return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; - } - } - const reader = request.body?.getReader(); - // iroh-relay's access-mode POST carries no body at all. - if (!reader) return { ok: true, bytes: new Uint8Array() }; - const chunks: Uint8Array[] = []; - let total = 0; - let timedOut = false; - // Cancelling the reader on expiry resolves the pending read() and releases - // the underlying stream: real cancellation, not an abandoned promise. - const timer = setTimeout(() => { - timedOut = true; - void reader.cancel().catch(() => undefined); - }, timeoutMs); - try { - while (true) { - const next = await reader.read(); - if (next.done) break; - total += next.value.byteLength; - if (total > MAX_BODY_BYTES) { - await reader.cancel(); - return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; - } - chunks.push(next.value); - } - } catch { - if (!timedOut) { - return { ok: false, response: jsonResponse({ error: "invalid_body" }, 400) }; - } - } finally { - clearTimeout(timer); - } - if (timedOut) { - return { ok: false, response: jsonResponse({ error: "request_read_timeout" }, 408) }; - } - return { - ok: true, - bytes: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total), - }; -} - export function POST(request: Request): Promise { return handleRelayAllowRequest(request, productionDeps); } diff --git a/web/app/api/relay/policy/route.ts b/web/app/api/relay/policy/route.ts new file mode 100644 index 000000000000..fc5f9bd45d5d --- /dev/null +++ b/web/app/api/relay/policy/route.ts @@ -0,0 +1,96 @@ +// Serve the signed, server-driven Iroh relay policy for the caller's account. +// Relay admission is decided by the relay's allow hook (/api/relay/allow), so +// this route carries no credentials: clients prove identity in the iroh +// handshake and only need the signed catalog plus their account preference. +// Auth is native-only because the policy names account-scoped infrastructure. + +import { checkRateLimit } from "@vercel/firewall"; + +import { + enforceRelayRateLimit, + jsonResponse, + relayErrorResponse, + runRelayEffect, + type RelayRateLimitCheck, +} from "../../../../services/relay/http"; +import { + productionRelayWorkflowConfig, + signedRelayPolicy, + type SignedRelayPolicyResult, +} from "../../../../services/relay/workflows"; +import { runRelayRepositoryEffect } from "../../../../services/relay/repository"; +import { relayAuthenticationError } from "../../../../services/relay/errors"; +import { + unauthorized, + verifyRequest, + type AuthedUser, +} from "../../../../services/vms/auth"; + +const RELAY_POLICY_RATE_LIMIT_BUCKET_SECONDS = 60; + +export interface RelayPolicyDeps { + readonly verifyRequest: (request: Request) => Promise; + readonly nowSeconds: () => number; + readonly signedPolicy: ( + accountId: string, + nowSeconds: number, + ) => Promise; + readonly checkRateLimit: RelayRateLimitCheck; + readonly rateLimitRuleId: () => string | undefined; + readonly isVercel: () => boolean; +} + +const productionDeps: RelayPolicyDeps = { + verifyRequest: (request) => verifyRequest(request, { allowCookie: false }), + nowSeconds: () => Math.floor(Date.now() / 1_000), + signedPolicy: async (accountId, nowSeconds) => { + const config = productionRelayWorkflowConfig(); + return await runRelayRepositoryEffect(signedRelayPolicy(accountId, { + ...config, + nowSeconds, + })); + }, + checkRateLimit, + // Reuses the account-scoped rule that previously gated token minting. + rateLimitRuleId: () => process.env.CMUX_RELAY_TOKEN_RATE_LIMIT_ID, + isVercel: () => process.env.VERCEL === "1", +}; + +export async function handleRelayPolicyRequest( + request: Request, + deps: RelayPolicyDeps, +): Promise { + let user: AuthedUser | null; + try { + user = await deps.verifyRequest(request); + } catch (error) { + return relayErrorResponse(relayAuthenticationError(error)); + } + if (!user) return unauthorized(); + + try { + const nowSeconds = deps.nowSeconds(); + const retryAfterSeconds = RELAY_POLICY_RATE_LIMIT_BUCKET_SECONDS - + (nowSeconds % RELAY_POLICY_RATE_LIMIT_BUCKET_SECONDS); + await runRelayEffect(enforceRelayRateLimit({ + request, + accountId: user.id, + ruleId: deps.rateLimitRuleId(), + check: deps.checkRateLimit, + isVercel: deps.isVercel(), + retryAfterSeconds, + })); + const policy = await deps.signedPolicy(user.id, nowSeconds); + return jsonResponse({ + policy: policy.policy, + preference: policy.preference, + preferenceRevision: policy.preferenceRevision, + }); + } catch (error) { + return relayErrorResponse(error); + } +} + +export function GET(request: Request): Promise { + return handleRelayPolicyRequest(request, productionDeps); +} diff --git a/web/app/api/relay/report/route.ts b/web/app/api/relay/report/route.ts new file mode 100644 index 000000000000..52a7130b12e6 --- /dev/null +++ b/web/app/api/relay/report/route.ts @@ -0,0 +1,141 @@ +// Attach/detach report sink for the self-hosted relay fleet (cmux-relay +// `Reporter`): fire-and-forget HMAC-signed POSTs of +// `{endpointId, event, relayId, ts}` on every admitted connect and every +// disconnect. Applying one publishes "endpoint X reachable via relay Y" into +// the registry, which discovery serves to the account's other devices — the +// server-side replacement for the Mac's client-side post-attach republish. +// +// Hardening mirrors /api/relay/allow: shared-secret HMAC over the raw body +// (fail closed to 503 when the secret is unset), bounded body read with a +// hard deadline, a response-latency bound on the database application, a +// dedicated deadline-bounded connection pool, a hard concurrency cap, and +// `cache-control: no-store` on every response. The relay treats any non-2xx +// as a logged warning, never a retry loop, so degraded answers are safe. + +import { env } from "../../../env"; +import { jsonResponse, readBoundedBody } from "../../../../services/relay/http"; +import { + parseRelayAllowSecret, + verifyRelayAllowSignature, +} from "../../../../services/relay/allow"; +import { + RELAY_REPORT_SIGNATURE_HEADER, + RelayReportSaturatedError, + applyRelayAttachReport, + parseRelayAttachReport, + type RelayAttachReport, + type RelayReportOutcome, +} from "../../../../services/relay/report"; + +const MAX_BODY_BYTES = 4 * 1_024; +const BODY_READ_TIMEOUT_MS = 5_000; +// Response-latency bound for the registry update, so a stalled database +// cannot hold report handlers (and their concurrency slots) until some +// external timeout. Expiry fails to 503; the next attach/detach event +// self-heals the published state. The resource bounds live in +// services/relay/report.ts (statement timeout, settle bound, dedicated pool, +// concurrency cap). +const APPLY_TIMEOUT_MS = 3_000; + +export interface RelayReportDeps { + readonly secretBase64: () => string | undefined; + readonly apply: (report: RelayAttachReport) => Promise; + readonly applyTimeoutMs?: number; + readonly bodyReadTimeoutMs?: number; + readonly now?: () => Date; +} + +const productionDeps: RelayReportDeps = { + secretBase64: () => env.CMUX_RELAY_ALLOW_HMAC_SECRET_B64, + apply: applyRelayAttachReport, +}; + +export async function handleRelayReportRequest( + request: Request, + deps: RelayReportDeps, +): Promise { + const secret = parseRelayAllowSecret(deps.secretBase64()); + if (!secret) return jsonResponse({ error: "relay_report_not_configured" }, 503); + + const body = await readBoundedBody(request, { + maxBytes: MAX_BODY_BYTES, + timeoutMs: deps.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS, + }); + if (!body.ok) return body.response; + + // Reports always carry the signature header (the relay signs the exact + // body bytes); no bearer fallback exists on this route. + const provided = request.headers.get(RELAY_REPORT_SIGNATURE_HEADER)?.trim(); + if (!provided || !verifyRelayAllowSignature(secret, body.bytes, provided)) { + return jsonResponse({ error: "invalid_relay_report_signature" }, 401); + } + + if (body.bytes.byteLength === 0) { + return jsonResponse({ error: "missing_report_body" }, 400); + } + let value: unknown; + try { + value = JSON.parse(Buffer.from(body.bytes).toString("utf8")); + } catch { + return jsonResponse({ error: "invalid_json" }, 400); + } + const parsed = parseRelayAttachReport(value, (deps.now ?? (() => new Date()))()); + if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400); + + try { + return outcomeResponse(await applyWithDeadline(deps, parsed.report)); + } catch (error) { + if (error instanceof RelayReportSaturatedError) { + return jsonResponse({ error: "relay_report_saturated" }, 503); + } + // Never log EndpointIDs; the route and failure class are enough. + console.error("relay report application failed", { failure: "unexpected" }); + return jsonResponse({ error: "relay_report_unavailable" }, 503); + } +} + +async function applyWithDeadline( + deps: RelayReportDeps, + report: RelayAttachReport, +): Promise { + let timer: ReturnType | undefined; + const application = deps.apply(report); + // An application that settles (typically rejecting on the database-side + // statement_timeout) after losing the race must not surface as an + // unhandled rejection. + application.catch(() => undefined); + try { + return await Promise.race([ + application, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("relay_report_apply_timeout")), + deps.applyTimeoutMs ?? APPLY_TIMEOUT_MS, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +function outcomeResponse(outcome: RelayReportOutcome): Response { + switch (outcome) { + case "applied": + return jsonResponse({ applied: true }); + case "superseded": + case "unknown_endpoint": + // Stale orderings and reports about endpoints that no longer have an + // active binding are normal fleet operation, not caller errors. + return jsonResponse({ applied: false, reason: outcome }); + case "untrusted_relay": + // A syntactically valid, correctly signed report about a relay outside + // the account's dialable set: refuse loudly so the relay's warn log + // shows the misconfiguration (e.g. a dev relay pointed at production). + return jsonResponse({ error: "untrusted_relay" }, 403); + } +} + +export function POST(request: Request): Promise { + return handleRelayReportRequest(request, productionDeps); +} diff --git a/web/app/api/relay/token/route.ts b/web/app/api/relay/token/route.ts deleted file mode 100644 index 296e4a2fd324..000000000000 --- a/web/app/api/relay/token/route.ts +++ /dev/null @@ -1,335 +0,0 @@ -// Mint endpoint-bound access credentials and a signed, server-driven Iroh relay policy. -// Auth is native-only because both credentials leave the browser boundary. - -import type { KeyObject } from "node:crypto"; - -import { checkRateLimit } from "@vercel/firewall"; -import * as Effect from "effect/Effect"; - -import { readBoundedJsonObject } from "../../../../services/apns/routePolicy"; -import { - enforceRelayRateLimit, - jsonResponse, - relayErrorResponse, - runRelayEffect, - type RelayRateLimitCheck, -} from "../../../../services/relay/http"; -import { - isValidEndpointId, - mintManagedRelayCredentials, - relaySigningKey, - type ManagedRelayCredentialGrant, -} from "../../../../services/relay/token"; -import { - RelayConfigurationError, - RelayDatabaseError, - relayAuthenticationError, -} from "../../../../services/relay/errors"; -import { - productionRelayWorkflowConfig, - signedRelayPolicy, - type SignedRelayPolicyResult, -} from "../../../../services/relay/workflows"; -import { runRelayRepositoryEffect } from "../../../../services/relay/repository"; -import { - IrohRepository, - IrohRepositoryLive, -} from "../../../../services/iroh/repository"; -import { - verifyBindingRequestSignature, - type IrohBindingRequestProof, -} from "../../../../services/iroh/crypto"; -import { - parseBindingRequestProof, -} from "../../../../services/iroh/routeHandler"; -import { - unauthorized, - verifyRequest, - type AuthedUser, -} from "../../../../services/vms/auth"; - - -const MAX_BODY_BYTES = 4 * 1_024; -const RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS = 60; - -export interface RelayTokenDeps { - readonly verifyRequest: (request: Request) => Promise; - readonly signingKey: () => KeyObject | null; - readonly nowSeconds: () => number; - readonly signedPolicy: ( - accountId: string, - nowSeconds: number, - ) => Promise; - readonly issueCredentials: (input: { - readonly accountId: string; - readonly endpointId: string; - readonly relayUrls: readonly string[]; - readonly key: KeyObject; - readonly nowSeconds: number; - }) => readonly ManagedRelayCredentialGrant[]; - readonly isEndpointAuthorized: (input: { - readonly accountId: string; - readonly endpointId: string; - readonly clientNamespace: string; - readonly nowSeconds: number; - readonly bindingProof: IrohBindingRequestProof | undefined; - }) => Promise; - readonly checkRateLimit: RelayRateLimitCheck; - readonly rateLimitRuleId: () => string | undefined; - readonly isVercel: () => boolean; - readonly credentialSigningRequired: () => boolean; -} - -const productionDeps: RelayTokenDeps = { - verifyRequest: (request) => verifyRequest(request, { allowCookie: false }), - signingKey: relaySigningKey, - nowSeconds: () => Math.floor(Date.now() / 1_000), - signedPolicy: async (accountId, nowSeconds) => { - const config = productionRelayWorkflowConfig(); - return await runRelayRepositoryEffect(signedRelayPolicy(accountId, { - ...config, - nowSeconds, - })); - }, - issueCredentials: (input) => mintManagedRelayCredentials({ - sub: input.accountId, - endpointId: input.endpointId, - relayUrls: input.relayUrls, - key: input.key, - nowSeconds: input.nowSeconds, - }), - isEndpointAuthorized: async (input) => await runRelayEffect( - Effect.gen(function* () { - const repository = yield* IrohRepository; - const binding = yield* repository.findActiveBindingByEndpoint( - input.accountId, - input.endpointId, - ); - if (!binding || binding.clientNamespace !== input.clientNamespace) { - return false; - } - if (!input.bindingProof) return input.clientNamespace === "legacy"; - if (input.bindingProof.bindingId !== binding.id) return false; - try { - verifyBindingRequestSignature({ - ...input.bindingProof, - endpointId: binding.endpointId, - nowSeconds: input.nowSeconds, - }); - return true; - } catch { - return false; - } - }).pipe( - Effect.provide(IrohRepositoryLive), - Effect.mapError((cause) => new RelayDatabaseError({ - operation: "irohBinding.findByEndpoint", - cause, - })), - ), - ), - checkRateLimit, - rateLimitRuleId: () => process.env.CMUX_RELAY_TOKEN_RATE_LIMIT_ID, - isVercel: () => process.env.VERCEL === "1", - credentialSigningRequired: () => - process.env.VERCEL === "1" && process.env.VERCEL_ENV !== "preview", -}; - -export async function handleRelayTokenRequest( - request: Request, - deps: RelayTokenDeps, -): Promise { - // Apply the cheap IP-scoped gate before calling Stack Auth. A storming - // client must not spend one upstream users/me request per retry. The clone - // preserves the existing auth-first semantics for malformed requests, which - // must not consume a valid relay-token budget. - if (await hasValidRelayEndpoint(request)) { - try { - await runRelayEffect(enforceRelayRateLimit({ - request, - accountId: "pre-auth", - rateLimitKey: null, - ruleId: deps.rateLimitRuleId(), - check: deps.checkRateLimit, - isVercel: deps.isVercel(), - retryAfterSeconds: RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS, - })); - } catch (error) { - return relayErrorResponse(error); - } - } - - let user: AuthedUser | null; - try { - user = await deps.verifyRequest(request); - } catch (error) { - return relayErrorResponse(relayAuthenticationError(error)); - } - if (!user) return unauthorized(); - const clientNamespace = request.headers.get("x-cmux-app-namespace") ?? "legacy"; - if (!/^[A-Za-z0-9._:-]{1,255}$/.test(clientNamespace)) { - return jsonResponse({ error: "invalid_client_namespace" }, 400); - } - const proofRequest = request.clone(); - - try { - const key = deps.signingKey(); - const body = await readBoundedJsonObject(request, MAX_BODY_BYTES); - if (!body.ok) { - return jsonResponse( - { error: body.error }, - body.error === "request_too_large" ? 413 : 400, - ); - } - const rawEndpointId = body.value.endpointId; - if (typeof rawEndpointId !== "string" || !isValidEndpointId(rawEndpointId)) { - return jsonResponse({ error: "invalid_endpoint_id" }, 400); - } - const bindingProof = parseBindingRequestProof( - proofRequest, - new Uint8Array(await proofRequest.arrayBuffer()), - ); - if (bindingProof instanceof Response) return bindingProof; - if (clientNamespace !== "legacy" && !bindingProof) { - return jsonResponse({ error: "binding_request_proof_required" }, 403); - } - - const nowSeconds = deps.nowSeconds(); - const endpointId = rawEndpointId.toLowerCase(); - const isEndpointAuthorized = await deps.isEndpointAuthorized({ - accountId: user.id, - endpointId, - clientNamespace, - nowSeconds, - bindingProof, - }); - if (clientNamespace !== "legacy" && !isEndpointAuthorized) { - return jsonResponse({ error: "invalid_binding_request_proof" }, 403); - } - - const policy = await deps.signedPolicy(user.id, nowSeconds); - if (!key && deps.credentialSigningRequired()) { - return jsonResponse({ error: "relay_token_not_configured" }, 503); - } - const relayUrls = policy.payload.relays.map((relay) => relay.url); - // A fresh endpoint must fetch policy before registration, then fetch its - // bound credential immediately after registration. Renewals happen every - // four minutes because both artifacts expire after five. Give bootstrap - // and credential issuance separate one-minute partitions so the external - // rule cannot make the valid two-leg bootstrap or renewal cadence - // impossible. Duplicate work inside one phase and minute is still bounded. - const rateLimitBucket = Math.floor( - nowSeconds / RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS, - ); - const rateLimitPhase = isEndpointAuthorized ? "credential" : "bootstrap"; - const retryAfterSeconds = RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS - - (nowSeconds % RELAY_TOKEN_RATE_LIMIT_BUCKET_SECONDS); - await runRelayEffect(enforceRelayRateLimit({ - request, - accountId: user.id, - devicePartition: - `${endpointId}:${rateLimitPhase}:${rateLimitBucket}`, - ruleId: deps.rateLimitRuleId(), - check: deps.checkRateLimit, - isVercel: deps.isVercel(), - retryAfterSeconds, - })); - - // Local and preview runtimes intentionally operate without the private - // relay JWT signer. They still return the signed fleet policy so clients - // install one coherent account preference and continue with direct/LAN - // paths. Deployed non-preview runtimes fail closed above. - const relayCredentials = isEndpointAuthorized && key - ? deps.issueCredentials({ - accountId: user.id, - endpointId, - relayUrls, - key, - nowSeconds, - }) - : undefined; - if ( - relayCredentials !== undefined && - !hasExactCredentialSet(relayCredentials, relayUrls, nowSeconds) - ) { - throw new RelayConfigurationError({ code: "credential_set_invalid" }); - } - const legacy = relayCredentials - ? homogeneousLegacyCredential(relayCredentials) - : null; - return jsonResponse({ - endpointId, - ...(relayCredentials ? { relayCredentials } : {}), - // Homogeneous fleets retain the old fields during client migration. - ...(legacy - ? { - token: legacy.token, - expiresAt: legacy.expiresAt, - ttlSeconds: legacy.ttlSeconds, - relays: relayUrls, - } - : {}), - policy: policy.policy, - preference: policy.preference, - preferenceRevision: policy.preferenceRevision, - }); - } catch (error) { - return relayErrorResponse(error); - } -} - -function hasExactCredentialSet( - credentials: readonly ManagedRelayCredentialGrant[], - relayUrls: readonly string[], - nowSeconds: number, -): boolean { - if (credentials.length !== relayUrls.length || credentials.length === 0) { - return false; - } - const expected = new Set(relayUrls); - const observed = new Set(); - for (const credential of credentials) { - if ( - !expected.has(credential.relayUrl) || - observed.has(credential.relayUrl) || - credential.token.length === 0 || - credential.token.length > 8 * 1_024 || - credential.ttlSeconds < 30 || - credential.ttlSeconds > 24 * 60 * 60 || - credential.expiresAt <= credential.refreshAfter || - credential.refreshAfter <= nowSeconds || - credential.refreshAfter < credential.expiresAt - credential.ttlSeconds - ) { - return false; - } - observed.add(credential.relayUrl); - } - return observed.size === expected.size; -} - -function homogeneousLegacyCredential( - credentials: readonly ManagedRelayCredentialGrant[], -): ManagedRelayCredentialGrant | null { - const first = credentials[0]; - if (!first) return null; - return credentials.every((credential) => - credential.token === first.token && - credential.expiresAt === first.expiresAt && - credential.refreshAfter === first.refreshAfter && - credential.ttlSeconds === first.ttlSeconds - ) ? first : null; -} - -export function POST(request: Request): Promise { - return handleRelayTokenRequest(request, productionDeps); -} - -async function hasValidRelayEndpoint(request: Request): Promise { - try { - const body = await readBoundedJsonObject(request.clone(), MAX_BODY_BYTES); - const endpointId = body.ok ? body.value.endpointId : undefined; - return typeof endpointId === "string" && isValidEndpointId(endpointId); - } catch { - return false; - } -} diff --git a/web/app/env.ts b/web/app/env.ts index 7bc0eba4598f..8a550b3a88c6 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -1,10 +1,5 @@ import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; -import { - insecureLoopbackMinterAllowed, - parseIrohMinterUrl, - type IrohMinterUrlPolicy, -} from "../services/iroh/minterUrlPolicy"; // Trim at the runtimeEnv source so every consumer — including paths that // run when validation is skipped (VERCEL_ENV === "preview") — sees clean @@ -31,13 +26,6 @@ const isVercelProductionDeployment = process.env.VERCEL === "1" && process.env.VERCEL_ENV === "production" && !isDocsZone; -const irohMinterUrlPolicy: IrohMinterUrlPolicy = { - allowInsecureLoopback: - trimEnv(process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER) === "1", - deploymentEnvironment: - process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", - isVercelDeployment: process.env.VERCEL === "1", -}; const requireVercelNonPreviewValue = ( name: string, schema: z.ZodType = z.string().min(1), @@ -86,7 +74,6 @@ const retiredEnvValue = ( } }); const privateRelayEnvNames = new Set([ - "CMUX_RELAY_JWT_PRIVATE_KEY_PEM", "CMUX_RELAY_POLICY_KEY_ID", "CMUX_RELAY_POLICY_PRIVATE_KEY_PEM", ]); @@ -116,32 +103,6 @@ const publicEnvValidationIssues = (issues: readonly unknown[]): readonly unknown } return publicIssues; }; -const localDevelopmentOptIn = (name: string) => - z.enum(["0", "1"]).optional().superRefine((value, context) => { - if ( - value === "1" && - !insecureLoopbackMinterAllowed({ - ...irohMinterUrlPolicy, - allowInsecureLoopback: true, - }) - ) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: `${name} is only allowed in local development`, - }); - } - }); -const irohMinterUrl = z.string().url().superRefine((value, context) => { - try { - parseIrohMinterUrl(value, irohMinterUrlPolicy); - } catch { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: - "CMUX_IROH_MINT_URL must use HTTPS, except for an opted-in local loopback development minter", - }); - } -}); const irohBindingLimit = z.string().regex(/^[1-9][0-9]{0,3}$/).superRefine((value, context) => { if (Number(value) > 4_096) { context.addIssue({ @@ -258,8 +219,8 @@ export const env = createEnv({ }) .optional(), // Iroh trust broker. The Services API key deliberately has no TypeScript - // env entry: only the isolated Rust relay minter may hold it. These values - // are server-only and routes fail closed when an operation's key is absent. + // env entry. These values are server-only and routes fail closed when an + // operation's key is absent. CMUX_IROH_LAN_DISCOVERY_SECRET_B64: requireVercelNonPreviewValue( "CMUX_IROH_LAN_DISCOVERY_SECRET_B64", z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), @@ -280,11 +241,6 @@ export const env = createEnv({ "CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON", z.string().min(2).max(32_768), ), - // Optional compatibility path for n0-hosted relay credentials. The - // self-hosted fleet mints endpoint-bound JWTs through /api/relay/token. - CMUX_IROH_MINT_URL: irohMinterUrl.optional(), - CMUX_IROH_MINT_HMAC_SECRET_B64: - z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), // Optional: leave unset to disable iroh rate limiting entirely. When unset, // the firewall gate in routeHandler.ts is skipped. Matches the other // optional rate-limit IDs (for example @@ -297,20 +253,14 @@ export const env = createEnv({ // Server-to-worker authentication for revision publication. Native clients // hold only their Stack access token and can never mint invalidations. CMUX_CONNECTIVITY_INVALIDATION_SECRET: z.string().min(32).max(512).optional(), - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: localDevelopmentOptIn( - "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER", - ), CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: z.enum(["0", "1"]).optional(), CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: z.string().max(8_192).optional(), CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: z.string().max(256).optional(), CMUX_IROH_DEV_BINDING_ACCOUNT_LIMIT: irohBindingLimit.optional(), CMUX_IROH_DEV_BINDING_DEVICE_LIMIT: irohBindingLimit.optional(), - // Self-hosted relay fleet. Preview and local builds remain credential-free, - // while every deployed non-preview runtime must be able to mint endpoint- - // bound credentials, sign the fleet policy, and enforce its account limit. - CMUX_RELAY_JWT_PRIVATE_KEY_PEM: requireVercelRelayValue( - z.string().min(64).max(16_384), - ), + // Self-hosted relay fleet. Relay admission is the allow hook; the web API + // only signs the fleet policy. Every deployed non-preview runtime must be + // able to sign that policy and enforce its account limit. CMUX_RELAY_POLICY_KEY_ID: requireVercelRelayValue( z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/), ), @@ -326,8 +276,8 @@ export const env = createEnv({ CMUX_RELAY_PREFERENCES_RATE_LIMIT_ID: z.string().min(1).optional(), // Shared secret for the relay fleet's per-connection access-control hook // (POST /api/relay/allow). Optional: when unset the route answers 503 and - // the fleet fails closed for new endpoint admissions. Same base64 shape as - // CMUX_IROH_MINT_HMAC_SECRET_B64. + // the fleet fails closed for new endpoint admissions. 32-byte random + // secret in standard base64. CMUX_RELAY_ALLOW_HMAC_SECRET_B64: z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), }, @@ -405,22 +355,16 @@ export const env = createEnv({ CMUX_IROH_GRANT_SIGNING_KEY_P8: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KEY_P8), CMUX_IROH_GRANT_SIGNING_KID: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KID), CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: trimEnv(process.env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON), - CMUX_IROH_MINT_URL: trimEnv(process.env.CMUX_IROH_MINT_URL), - CMUX_IROH_MINT_HMAC_SECRET_B64: trimEnv(process.env.CMUX_IROH_MINT_HMAC_SECRET_B64), CMUX_IROH_RATE_LIMIT_ID: trimEnv(process.env.CMUX_IROH_RATE_LIMIT_ID), CMUX_PRESENCE_BASE_URL: trimEnv(process.env.CMUX_PRESENCE_BASE_URL), CMUX_CONNECTIVITY_INVALIDATION_SECRET: trimEnv( process.env.CMUX_CONNECTIVITY_INVALIDATION_SECRET, ), - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: trimEnv( - process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER, - ), CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED), CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS), CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), CMUX_IROH_DEV_BINDING_ACCOUNT_LIMIT: trimEnv(process.env.CMUX_IROH_DEV_BINDING_ACCOUNT_LIMIT), CMUX_IROH_DEV_BINDING_DEVICE_LIMIT: trimEnv(process.env.CMUX_IROH_DEV_BINDING_DEVICE_LIMIT), - CMUX_RELAY_JWT_PRIVATE_KEY_PEM: trimEnv(process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM), CMUX_RELAY_POLICY_KEY_ID: trimEnv(process.env.CMUX_RELAY_POLICY_KEY_ID), CMUX_RELAY_POLICY_PRIVATE_KEY_PEM: trimEnv(process.env.CMUX_RELAY_POLICY_PRIVATE_KEY_PEM), CMUX_RELAY_TOKEN_RATE_LIMIT_ID: trimEnv(process.env.CMUX_RELAY_TOKEN_RATE_LIMIT_ID), diff --git a/web/db/migrations/20260825120000_iroh_relay_attach_reports/migration.sql b/web/db/migrations/20260825120000_iroh_relay_attach_reports/migration.sql new file mode 100644 index 000000000000..81615b7fb8a3 --- /dev/null +++ b/web/db/migrations/20260825120000_iroh_relay_attach_reports/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "relay_attached_url" text; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "relay_attach_reported_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_relay_attached_url_check" + CHECK ("relay_attached_url" IS NULL OR ("relay_attached_url" ~ '^https://' AND length("relay_attached_url") <= 2048)); +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_relay_attach_reported_check" + CHECK ("relay_attached_url" IS NULL OR "relay_attach_reported_at" IS NOT NULL); diff --git a/web/db/schema.ts b/web/db/schema.ts index 88d3197818a3..70c747d58e4c 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -982,6 +982,14 @@ export const irohEndpointBindings = pgTable( directPortV6: integer("direct_port_v6"), pathHints: jsonb("path_hints").$type().notNull().default(sql`'[]'::jsonb`), pathHintsNextExpiry: timestamp("path_hints_next_expiry", { withTimezone: true }), + // Live relay attach state, written only by HMAC-verified fleet reports + // (POST /api/relay/report). `relayAttachedUrl` is the exact catalog or + // saved-custom relay URL the endpoint is currently attached through; + // `relayAttachReportedAt` is the relay-side event timestamp of the last + // APPLIED report and orders fire-and-forget reports that arrive out of + // order. Cleared by a matching detach report and by revocation. + relayAttachedUrl: text("relay_attached_url"), + relayAttachReportedAt: timestamp("relay_attach_reported_at", { withTimezone: true }), deviceLimitOverrideUsed: boolean("device_limit_override_used").notNull().default(false), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), registeredAt: timestamp("registered_at", { withTimezone: true }).notNull().defaultNow(), @@ -1000,6 +1008,15 @@ export const irohEndpointBindings = pgTable( check("iroh_endpoint_bindings_direct_port_v4_check", sql`${table.directPortV4} is null or ${table.directPortV4} between 1 and 65535`), check("iroh_endpoint_bindings_direct_port_v6_check", sql`${table.directPortV6} is null or ${table.directPortV6} between 1 and 65535`), check("iroh_endpoint_bindings_path_hints_check", sql`jsonb_typeof(${table.pathHints}) = 'array' and jsonb_array_length(${table.pathHints}) <= 16`), + check( + "iroh_endpoint_bindings_relay_attached_url_check", + sql`${table.relayAttachedUrl} is null or (${table.relayAttachedUrl} ~ '^https://' and length(${table.relayAttachedUrl}) <= 2048)`, + ), + // A published attach URL always carries the event timestamp that set it. + check( + "iroh_endpoint_bindings_relay_attach_reported_check", + sql`${table.relayAttachedUrl} is null or ${table.relayAttachReportedAt} is not null`, + ), uniqueIndex("iroh_endpoint_bindings_active_endpoint_unique") .on(table.endpointId) .where(sql`${table.revokedAt} is null`), diff --git a/web/services/connectivity/routeHandler.ts b/web/services/connectivity/routeHandler.ts index ea472e1164de..a62bd68680e7 100644 --- a/web/services/connectivity/routeHandler.ts +++ b/web/services/connectivity/routeHandler.ts @@ -148,21 +148,8 @@ function connectivityExpectedErrorResponse( return connectivityJsonResponse({ error: `${error.resource}_not_found` }, 404); case "IrohConflictError": return connectivityJsonResponse({ error: error.code }, 409); - case "IrohQuotaExceededError": - return new Response(JSON.stringify({ - error: error.code, - retry_after_seconds: error.retryAfterSeconds, - }), { - status: 429, - headers: { - "content-type": "application/json", - "cache-control": "no-store", - "retry-after": String(error.retryAfterSeconds), - }, - }); case "IrohConfigurationError": case "IrohDatabaseError": - case "IrohRelayMintError": return connectivityJsonResponse({ error: "connectivity_service_unavailable" }, 503); } } diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index 06f728d81b8b..efd0af8fd952 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -45,33 +45,25 @@ the Stack credential. There is no total active-binding limit per account or device. Postgres advisory locks keep request-rate limits concurrency-safe: six challenges per device per -ten minutes, 32 outstanding challenges per account, 60 pair grants per account -per hour, three relay mints per endpoint per ten minutes, 12 relay mints per -endpoint per day, and 100 relay mints per account per day. A relay reservation -remains active for 60 seconds, then the next account-scoped reservation marks it -expired before applying those quotas. The optional Vercel Firewall rule is -defense in depth. A tagged-build override widens challenge issuance only after -an exact authenticated user-id and deployment-environment allowlist match. +ten minutes, 32 outstanding challenges per account, and 60 pair grants per +account per hour. The optional Vercel Firewall rule is defense in depth. A +tagged-build override widens challenge issuance only after an exact +authenticated user-id and deployment-environment allowlist match. -Registration bootstraps a relay credential only when it creates a binding. -Signed refreshes of the same binding return `relay.status = "not_requested"`; -clients retain their existing credential or use the dedicated relay-token route -when its refresh window arrives. Platform is part of the immutable binding -identity and requires explicit revocation before it can change. - -The n0-hosted relay minter is an optional compatibility path. When -`CMUX_IROH_MINT_URL` and `CMUX_IROH_MINT_HMAC_SECRET_B64` are absent, initial -registration returns `relay.status = "unavailable"` without rolling back the -binding. Current clients obtain endpoint-bound credentials for the self-hosted -fleet from `/api/relay/token`. +Registration never mints a relay credential. A newly created binding receives +`relay.status = "unavailable"` and signed refreshes of the same binding return +`relay.status = "not_requested"`; the fields exist only for wire compatibility. +Relay admission is decided server-side by the relay's allow hook +(`/api/relay/allow`) against the proven endpoint key, and clients fetch the +signed fleet policy from `/api/relay/policy`. Platform is part of the immutable +binding identity and requires explicit revocation before it can change. Every user-scoped mutation acquires the account-deletion advisory fence before any Iroh lock. If the deletion tombstone wins, no challenge, binding, grant, or relay audit state can be created. If an Iroh mutation wins, account deletion waits for that transaction and then removes its rows. Pair grants re-read and lock both exact signed peers at audit insertion, requiring an iOS initiator and -a pairable Mac acceptor. Relay credentials are returned only after a second -locked active-binding check following the external mint. +a pairable Mac acceptor. Registration stores the earliest managed-relay expiry in `path_hints_next_expiry`. The hourly cleanup uses that indexed scalar and diff --git a/web/services/iroh/config.ts b/web/services/iroh/config.ts index d015a12897d4..9b0bd91a1609 100644 --- a/web/services/iroh/config.ts +++ b/web/services/iroh/config.ts @@ -8,11 +8,6 @@ export type IrohTrustBrokerConfigShape = { readonly grantSigningPrivateKeyPem?: string; readonly grantSigningKid?: string; readonly grantVerificationKeysJson?: string; - readonly relayMinterUrl?: string; - readonly relayMinterHmacSecretBase64?: string; - readonly relayMinterInsecureLoopbackOptIn: boolean; - readonly deploymentEnvironment: string; - readonly isVercelDeployment: boolean; }; export class IrohTrustBrokerConfig extends Context.Tag("cmux/IrohTrustBrokerConfig")< @@ -27,12 +22,6 @@ export function irohTrustBrokerConfigFromEnv(): IrohTrustBrokerConfigShape { grantSigningPrivateKeyPem: env.CMUX_IROH_GRANT_SIGNING_KEY_P8, grantSigningKid: env.CMUX_IROH_GRANT_SIGNING_KID, grantVerificationKeysJson: env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, - relayMinterUrl: env.CMUX_IROH_MINT_URL, - relayMinterHmacSecretBase64: env.CMUX_IROH_MINT_HMAC_SECRET_B64, - relayMinterInsecureLoopbackOptIn: - env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER === "1", - deploymentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", - isVercelDeployment: process.env.VERCEL === "1", }; } diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts index 3b818ce76592..7ebbd427ad8c 100644 --- a/web/services/iroh/crypto.ts +++ b/web/services/iroh/crypto.ts @@ -12,8 +12,6 @@ import { IROH_ENDPOINT_ATTESTATION_SCOPE, IROH_ENDPOINT_ATTESTATION_TYP, IROH_ENDPOINT_ATTESTATION_VERSION, - IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS, - IROH_OFFLINE_PAIR_SESSION_VERSION, IROH_ALPN, IROH_PAIR_GRANT_LIFETIME_SECONDS, IROH_PAIR_GRANT_TYP, @@ -94,32 +92,6 @@ export type EndpointAttestationExpectation = { readonly nowSeconds: number; }; -export type OfflinePairVerificationExpectation = { - readonly initiator: Omit & { - readonly platform: "ios"; - }; - readonly acceptor: Omit & { - readonly platform: "mac"; - }; - readonly nowSeconds: number; -}; - -export type OfflinePairSessionRecord = { - readonly version: typeof IROH_OFFLINE_PAIR_SESSION_VERSION; - readonly sessionId: string; - readonly acceptor: OfflinePairVerificationExpectation["acceptor"]; - readonly proofHash: string; - readonly createdAtSeconds: number; - readonly expiresAtSeconds: number; - consumedAtSeconds: number | null; -}; - -export type OfflinePairInvitationProof = { - readonly version: typeof IROH_OFFLINE_PAIR_SESSION_VERSION; - readonly sessionId: string; - readonly proof: string; -}; - export function registrationTranscript(input: { readonly challengeId: string; readonly nonce: string; @@ -350,109 +322,6 @@ export function verifyEndpointAttestation( return claims; } -function verifyOfflineSameAccountPair(input: { - readonly initiatorAttestation: string; - readonly acceptorAttestation: string; - readonly publicKeys: ReadonlyMap; - readonly expected: OfflinePairVerificationExpectation; -}): { - readonly initiator: EndpointAttestationClaims; - readonly acceptor: EndpointAttestationClaims; -} { - if ( - input.expected.initiator.platform !== "ios" || - input.expected.acceptor.platform !== "mac" - ) { - throw new IrohForbiddenError({ code: "invalid_offline_pair_platforms" }); - } - const initiator = verifyEndpointAttestation(input.initiatorAttestation, input.publicKeys, { - ...input.expected.initiator, - nowSeconds: input.expected.nowSeconds, - }); - const acceptor = verifyEndpointAttestation(input.acceptorAttestation, input.publicKeys, { - ...input.expected.acceptor, - nowSeconds: input.expected.nowSeconds, - }); - if ( - initiator.bindingId === acceptor.bindingId || - initiator.deviceId === acceptor.deviceId || - initiator.endpointId === acceptor.endpointId || - !canonicalSubjectsEqual(initiator.sub, acceptor.sub) - ) { - throw new IrohForbiddenError({ code: "offline_pair_same_account_proof_required" }); - } - return { initiator, acceptor }; -} - -export function createOfflinePairSessionRecord(input: { - readonly sessionId: string; - readonly proof: string; - readonly acceptor: OfflinePairVerificationExpectation["acceptor"]; - readonly nowSeconds: number; - readonly expiresAtSeconds: number; -}): OfflinePairSessionRecord { - validateOfflinePairSessionWindow(input.nowSeconds, input.expiresAtSeconds); - validateEndpointExpectation(input.acceptor, "mac"); - if (!UUID_PATTERN.test(input.sessionId) || input.sessionId !== input.sessionId.toLowerCase()) { - throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); - } - const proof = decodeCanonicalBase64url(input.proof, 32, "invalid_offline_pair_proof"); - return { - version: IROH_OFFLINE_PAIR_SESSION_VERSION, - sessionId: input.sessionId.toLowerCase(), - acceptor: { ...input.acceptor }, - proofHash: offlinePairProofHash(input.sessionId.toLowerCase(), input.acceptor, proof), - createdAtSeconds: input.nowSeconds, - expiresAtSeconds: input.expiresAtSeconds, - consumedAtSeconds: null, - }; -} - -export function verifyAndConsumeOfflineSameAccountPair(input: { - readonly initiatorAttestation: string; - readonly acceptorAttestation: string; - readonly publicKeys: ReadonlyMap; - readonly expected: OfflinePairVerificationExpectation; - readonly session: OfflinePairSessionRecord; - readonly invitation: OfflinePairInvitationProof; -}): { - readonly initiator: EndpointAttestationClaims; - readonly acceptor: EndpointAttestationClaims; - readonly sessionId: string; -} { - const { session, invitation } = input; - if ( - typeof invitation.sessionId !== "string" || - !UUID_PATTERN.test(invitation.sessionId) || - invitation.sessionId !== invitation.sessionId.toLowerCase() - ) { - throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); - } - if ( - session.version !== IROH_OFFLINE_PAIR_SESSION_VERSION || - invitation.version !== IROH_OFFLINE_PAIR_SESSION_VERSION || - session.consumedAtSeconds !== null || - session.sessionId !== invitation.sessionId || - !sameEndpointExpectation(session.acceptor, input.expected.acceptor) || - session.createdAtSeconds > input.expected.nowSeconds + 30 || - session.expiresAtSeconds <= input.expected.nowSeconds - ) { - throw new IrohForbiddenError({ code: "offline_pair_session_unavailable" }); - } - validateOfflinePairSessionWindow( - session.createdAtSeconds, - session.expiresAtSeconds, - ); - const proof = decodeCanonicalBase64url(invitation.proof, 32, "invalid_offline_pair_proof"); - const actualHash = offlinePairProofHash(session.sessionId, session.acceptor, proof); - if (!hashesEqual(session.proofHash, actualHash)) { - throw new IrohForbiddenError({ code: "invalid_offline_pair_proof" }); - } - const verified = verifyOfflineSameAccountPair(input); - session.consumedAtSeconds = input.expected.nowSeconds; - return { ...verified, sessionId: session.sessionId }; -} - export function parseVerificationKeys( value: string | undefined, ): ParsedPairGrantVerificationKeys { @@ -827,65 +696,6 @@ function hasExactKeys(value: Record, allowed: readonly string[] return keys.length === allowed.length && keys.every((key) => allowed.includes(key)); } -function canonicalSubjectsEqual(left: string, right: string): boolean { - const leftBytes = decodeCanonicalBase64url(left, 32, "invalid_endpoint_attestation"); - const rightBytes = decodeCanonicalBase64url(right, 32, "invalid_endpoint_attestation"); - return timingSafeEqual(leftBytes, rightBytes); -} - -function validateOfflinePairSessionWindow(nowSeconds: number, expiresAtSeconds: number): void { - if ( - !Number.isSafeInteger(nowSeconds) || - !Number.isSafeInteger(expiresAtSeconds) || - expiresAtSeconds <= nowSeconds || - expiresAtSeconds - nowSeconds > IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS - ) { - throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); - } -} - -function validateEndpointExpectation( - value: OfflinePairVerificationExpectation["acceptor"], - platform: "mac" | "ios", -): void { - if ( - !UUID_PATTERN.test(value.bindingId) || - !UUID_PATTERN.test(value.deviceId) || - value.platform !== platform || - !Number.isSafeInteger(value.identityGeneration) || - value.identityGeneration < 1 || - value.identityGeneration > POSTGRES_INT32_MAX - ) { - throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); - } - endpointId(value.endpointId); -} - -function sameEndpointExpectation( - left: OfflinePairVerificationExpectation["acceptor"], - right: OfflinePairVerificationExpectation["acceptor"], -): boolean { - return left.bindingId === right.bindingId && - left.deviceId === right.deviceId && - left.endpointId === right.endpointId && - left.identityGeneration === right.identityGeneration && - left.platform === right.platform; -} - -function offlinePairProofHash( - sessionId: string, - acceptor: OfflinePairVerificationExpectation["acceptor"], - proof: Uint8Array, -): string { - return sha256(Buffer.concat([ - Buffer.from( - `cmux/iroh/offline-pair-session/v1\n${sessionId}\n${acceptor.bindingId}\n${acceptor.deviceId}\n${acceptor.endpointId}\n${acceptor.identityGeneration}\n${acceptor.platform}\n`, - "utf8", - ), - Buffer.from(proof), - ])); -} - const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function assertExactKeys( diff --git a/web/services/iroh/discoveryScope.ts b/web/services/iroh/discoveryScope.ts index b5e4603b9dd0..59c2340c4091 100644 --- a/web/services/iroh/discoveryScope.ts +++ b/web/services/iroh/discoveryScope.ts @@ -86,37 +86,6 @@ export function discoveryScopeMatchesRegistration( && scope.localBinding.platform === registration.platform; } -export function bindingMatchesDiscoveryScope( - binding: { - readonly deviceUuid: string; - readonly appInstanceId: string; - readonly tag: string; - readonly platform: string; - readonly pairingEnabled: boolean; - }, - scope: IrohDiscoveryScope, -): boolean { - const local = scope.localBinding; - if ( - binding.deviceUuid === local.deviceId - && binding.appInstanceId === local.appInstanceId - && binding.tag === local.tag - && binding.platform === local.platform - ) { - return true; - } - const peers = scope.peerBindings; - return binding.platform === peers.platform - && ( - peers.tags === undefined - || peers.tags.includes(binding.tag.toLowerCase()) - ) - && ( - peers.pairingEnabled === undefined - || binding.pairingEnabled === peers.pairingEnabled - ); -} - function peerTags(value: unknown): readonly string[] { if ( !Array.isArray(value) diff --git a/web/services/iroh/errors.ts b/web/services/iroh/errors.ts index 67d5214f3490..16c727160460 100644 --- a/web/services/iroh/errors.ts +++ b/web/services/iroh/errors.ts @@ -17,18 +17,12 @@ export class IrohConflictError extends Data.TaggedError("IrohConflictError")<{ readonly code: string; }> {} -export class IrohQuotaExceededError extends Data.TaggedError("IrohQuotaExceededError")<{ - readonly code: string; - readonly retryAfterSeconds: number; -}> {} - export class IrohConfigurationError extends Data.TaggedError("IrohConfigurationError")<{ readonly component: | "grant_signing" | "grant_verification" | "account_subject" - | "lan_discovery" - | "relay_minter"; + | "lan_discovery"; }> {} export class IrohDatabaseError extends Data.TaggedError("IrohDatabaseError")<{ @@ -36,20 +30,13 @@ export class IrohDatabaseError extends Data.TaggedError("IrohDatabaseError")<{ readonly cause: unknown; }> {} -export class IrohRelayMintError extends Data.TaggedError("IrohRelayMintError")<{ - readonly code: string; - readonly cause?: unknown; -}> {} - export type IrohExpectedError = | IrohInvalidInputError | IrohNotFoundError | IrohForbiddenError | IrohConflictError - | IrohQuotaExceededError | IrohConfigurationError - | IrohDatabaseError - | IrohRelayMintError; + | IrohDatabaseError; export function irohExpectedError(error: unknown): IrohExpectedError | null { if (!error || typeof error !== "object") return null; @@ -85,8 +72,6 @@ const IROH_ERROR_TAGS = new Set([ "IrohNotFoundError", "IrohForbiddenError", "IrohConflictError", - "IrohQuotaExceededError", "IrohConfigurationError", "IrohDatabaseError", - "IrohRelayMintError", ]); diff --git a/web/services/iroh/minterUrlPolicy.ts b/web/services/iroh/minterUrlPolicy.ts deleted file mode 100644 index 7aac5faac229..000000000000 --- a/web/services/iroh/minterUrlPolicy.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const IROH_RELAY_MINTER_PATH = "/api/relay-token"; - -export type IrohMinterUrlPolicy = { - readonly allowInsecureLoopback: boolean; - readonly deploymentEnvironment: string; - readonly isVercelDeployment: boolean; -}; - -export function insecureLoopbackMinterAllowed(policy: IrohMinterUrlPolicy): boolean { - return policy.allowInsecureLoopback && - !policy.isVercelDeployment && - policy.deploymentEnvironment === "development"; -} - -export function parseIrohMinterUrl(value: string, policy: IrohMinterUrlPolicy): URL { - const url = new URL(value); - const secureTransport = url.protocol === "https:"; - const allowedDevelopmentTransport = - url.protocol === "http:" && - insecureLoopbackMinterAllowed(policy) && - isCanonicalLoopbackHost(url.hostname); - - if ( - (!secureTransport && !allowedDevelopmentTransport) || - url.username || - url.password || - url.pathname !== IROH_RELAY_MINTER_PATH || - url.search || - url.hash - ) { - throw new Error("invalid Iroh relay minter URL"); - } - return url; -} - -function isCanonicalLoopbackHost(hostname: string): boolean { - return hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "[::1]"; -} diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index 7e474031775e..1e054add274a 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -16,10 +16,6 @@ export const IROH_ENDPOINT_ATTESTATION_SCOPE = "cmux.offline-pair.same-account"; export const IROH_CHALLENGE_LIFETIME_MS = 5 * 60 * 1_000; export const IROH_PAIR_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60; export const IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS = 24 * 60 * 60; -export const IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS = 5 * 60; -export const IROH_OFFLINE_PAIR_SESSION_VERSION = 1; -export const IROH_RELAY_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60; -export const IROH_RELAY_TOKEN_REFRESH_SECONDS = 12 * 60 * 60; export const IROH_ROUTE_CONTRACT_VERSION = 1; export const POSTGRES_INT32_MAX = 2_147_483_647; diff --git a/web/services/iroh/publicationPolicy.ts b/web/services/iroh/publicationPolicy.ts index e43abc27f3e4..3b61ac4dd671 100644 --- a/web/services/iroh/publicationPolicy.ts +++ b/web/services/iroh/publicationPolicy.ts @@ -26,14 +26,6 @@ type PathHintLike = { readonly privacy_scope?: string; }; -/** Keep only endpoint-reported managed relay URLs for server persistence. */ -export function serverPublishedIrohPathHints( - hints: readonly T[], -): T[] { - return hints.filter((hint) => - hint.kind === "relay_url" && MANAGED_RELAY_URL_SET.has(hint.value)); -} - /** * Keep only routes safe for the authenticated same-account broker. * @@ -57,6 +49,19 @@ export function accountPrivateIrohPathHints( }); } +/** + * May a server-observed relay attachment be published to the account's other + * devices? Same trust rule as endpoint-reported relay hints: only an exact + * managed-catalog URL or a relay the account has saved. Re-checked at read + * time so deleting a saved custom relay also stops serving its attach route. + */ +export function isPublishableAttachedRelayURL( + url: string, + savedCustomRelayURLs: ReadonlySet, +): boolean { + return MANAGED_RELAY_URL_SET.has(url) || savedCustomRelayURLs.has(url); +} + function plainRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record diff --git a/web/services/iroh/relayMinter.ts b/web/services/iroh/relayMinter.ts deleted file mode 100644 index 8bffeed91bf5..000000000000 --- a/web/services/iroh/relayMinter.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { createHash, createHmac } from "node:crypto"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { - IrohConfigurationError, - type IrohInvalidInputError, - IrohRelayMintError, -} from "./errors"; -import { IrohTrustBrokerConfig } from "./config"; -import { - IROH_RELAY_MINTER_PATH, - parseIrohMinterUrl, - type IrohMinterUrlPolicy, -} from "./minterUrlPolicy"; -import { IROH_RELAY_TOKEN_LIFETIME_SECONDS, endpointId } from "./model"; - -const MAX_MINTER_RESPONSE_BYTES = 32 * 1_024; -export { IROH_RELAY_MINTER_PATH }; - -export type IrohRelayMintResult = { - readonly token: string; - readonly expiresAt: Date; -}; - -export type IrohRelayMinterShape = { - readonly mint: (input: { - readonly endpointId: string; - readonly lifetimeSeconds: typeof IROH_RELAY_TOKEN_LIFETIME_SECONDS; - readonly now: Date; - }) => Effect.Effect< - IrohRelayMintResult, - IrohConfigurationError | IrohInvalidInputError | IrohRelayMintError - >; -}; - -export class IrohRelayMinter extends Context.Tag("cmux/IrohRelayMinter")< - IrohRelayMinter, - IrohRelayMinterShape ->() {} - -export const IrohRelayMinterLive = Layer.effect( - IrohRelayMinter, - Effect.gen(function* () { - const config = yield* IrohTrustBrokerConfig; - return { - mint: (input) => mintWithIsolatedService(config, input), - } satisfies IrohRelayMinterShape; - }), -); - -function mintWithIsolatedService( - config: typeof IrohTrustBrokerConfig.Service, - input: Parameters[0], -): Effect.Effect< - IrohRelayMintResult, - IrohConfigurationError | IrohInvalidInputError | IrohRelayMintError -> { - return Effect.tryPromise({ - try: async () => { - endpointId(input.endpointId); - const url = parseMinterUrl(config.relayMinterUrl, { - allowInsecureLoopback: config.relayMinterInsecureLoopbackOptIn, - deploymentEnvironment: config.deploymentEnvironment, - isVercelDeployment: config.isVercelDeployment, - }); - const secret = parseMinterHmacSecret(config.relayMinterHmacSecretBase64); - const body = JSON.stringify({ - endpointId: input.endpointId, - lifetimeSeconds: IROH_RELAY_TOKEN_LIFETIME_SECONDS, - }); - const timestamp = String(Math.floor(input.now.getTime() / 1_000)); - const bodyHash = createHash("sha256").update(body).digest("hex"); - const signature = createHmac("sha256", secret) - .update(`POST\n${url.pathname}\n${timestamp}\n${bodyHash}`, "utf8") - .digest("base64url"); - const response = await fetch(url, { - method: "POST", - redirect: "error", - signal: AbortSignal.timeout(10_000), - headers: { - "content-type": "application/json", - "x-cmux-iroh-timestamp": timestamp, - "x-cmux-iroh-signature": signature, - }, - body, - }); - if (!response.ok) throw new IrohRelayMintError({ code: "minter_rejected" }); - const raw = await readBoundedMinterJson(response); - if ( - typeof raw.token !== "string" || - raw.token.length < 16 || - raw.token.length > 16_384 || - !/^[a-z2-7]+$/.test(raw.token) - ) { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - if (typeof raw.expiresAt !== "string") throw new IrohRelayMintError({ code: "invalid_minter_response" }); - const expiresAt = new Date(raw.expiresAt); - const contractExpiry = input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000; - if ( - !Number.isFinite(expiresAt.getTime()) || - expiresAt <= input.now || - expiresAt.getTime() > contractExpiry + 60_000 || - expiresAt.getTime() < contractExpiry - 5 * 60_000 - ) { - throw new IrohRelayMintError({ code: "invalid_minter_expiry" }); - } - return { token: raw.token, expiresAt }; - }, - catch: (cause) => { - if ((cause as { _tag?: unknown } | null)?._tag === "IrohConfigurationError") { - return cause as IrohConfigurationError; - } - if ((cause as { _tag?: unknown } | null)?._tag === "IrohInvalidInputError") { - return cause as IrohInvalidInputError; - } - if ((cause as { _tag?: unknown } | null)?._tag === "IrohRelayMintError") { - return cause as IrohRelayMintError; - } - return new IrohRelayMintError({ code: "minter_unavailable", cause: safeCause(cause) }); - }, - }); -} - -export async function readBoundedMinterJson( - response: Response, -): Promise<{ token?: unknown; expiresAt?: unknown }> { - if ( - response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== - "application/json" - ) { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - const contentLength = response.headers.get("content-length"); - if (contentLength) { - const parsed = Number(contentLength); - if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_MINTER_RESPONSE_BYTES) { - throw new IrohRelayMintError({ code: "minter_response_too_large" }); - } - } - const reader = response.body?.getReader(); - if (!reader) throw new IrohRelayMintError({ code: "invalid_minter_response" }); - const chunks: Uint8Array[] = []; - let total = 0; - while (true) { - const next = await reader.read(); - if (next.done) break; - total += next.value.byteLength; - if (total > MAX_MINTER_RESPONSE_BYTES) { - await reader.cancel(); - throw new IrohRelayMintError({ code: "minter_response_too_large" }); - } - chunks.push(next.value); - } - const bytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total); - let parsed: unknown; - try { - parsed = JSON.parse(bytes.toString("utf8")); - } catch { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - const object = parsed as Record; - const keys = Object.keys(object); - if (keys.length !== 2 || !keys.includes("token") || !keys.includes("expiresAt")) { - throw new IrohRelayMintError({ code: "invalid_minter_response" }); - } - return object; -} - -export function parseMinterUrl( - value: string | undefined, - policy: IrohMinterUrlPolicy = { - allowInsecureLoopback: false, - deploymentEnvironment: "production", - isVercelDeployment: true, - }, -): URL { - if (!value) throw new IrohConfigurationError({ component: "relay_minter" }); - try { - return parseIrohMinterUrl(value, policy); - } catch { - throw new IrohConfigurationError({ component: "relay_minter" }); - } -} - -export function parseMinterHmacSecret(value: string | undefined): Buffer { - if (!value || value.length > 512) throw new IrohConfigurationError({ component: "relay_minter" }); - const decoded = Buffer.from(value, "base64"); - const canonicalPadded = decoded.toString("base64"); - const canonicalUnpadded = canonicalPadded.replace(/=+$/, ""); - if ( - decoded.byteLength < 32 || - decoded.byteLength > 256 || - (value !== canonicalPadded && value !== canonicalUnpadded) - ) { - throw new IrohConfigurationError({ component: "relay_minter" }); - } - return decoded; -} - -function safeCause(cause: unknown): unknown { - return cause instanceof Error ? { name: cause.name } : { type: typeof cause }; -} diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 4ad49a8ba9c6..327a85446a57 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -19,14 +19,12 @@ import { IrohDatabaseError, IrohForbiddenError, IrohNotFoundError, - IrohQuotaExceededError, } from "./errors"; import type { PairGrantPeer } from "./crypto"; import type { IrohDiscoveryCursor } from "./discoveryPagination"; import { nextPathHintExpiry, parseIrohPathHint, - sha256, type IrohPathHint, type IrohRegistrationPayload, } from "./model"; @@ -40,7 +38,6 @@ import type { IrohDiscoveryScope } from "./discoveryScope"; export const IROH_RETENTION_BATCH_SIZE = 500; export const IROH_RETENTION_MAX_ROWS = 10_000; export const IROH_RETENTION_MAX_DURATION_MS = 8_000; -export const IROH_RELAY_RESERVATION_LEASE_MS = 60 * 1_000; export type IrohRetentionCategory = | "revokedHints" @@ -76,8 +73,7 @@ type RepositoryError = | IrohDatabaseError | IrohForbiddenError | IrohNotFoundError - | IrohConflictError - | IrohQuotaExceededError; + | IrohConflictError; export type IrohRepositoryShape = { readonly issueChallenge: (input: { @@ -181,30 +177,6 @@ export type IrohRepositoryShape = { readonly notBefore: Date; readonly expiresAt: Date; }) => Effect.Effect; - readonly reserveRelayIssuance: (input: { - readonly userId: string; - readonly bindingId: string; - readonly clientNamespace?: string; - readonly now: Date; - }) => Effect.Effect<{ - readonly issuanceId: string; - readonly binding: IrohBindingRecord; - }, RepositoryError>; - readonly completeRelayIssuance: (input: { - readonly userId: string; - readonly issuanceId: string; - readonly bindingId: string; - readonly endpointId: string; - readonly tokenHash: string; - readonly completedAt: Date; - readonly expiresAt: Date; - }) => Effect.Effect; - readonly failRelayIssuance: (input: { - readonly userId: string; - readonly issuanceId: string; - readonly completedAt: Date; - readonly failureCode: string; - }) => Effect.Effect; }; export class IrohRepository extends Context.Tag("cmux/IrohRepository")< @@ -1145,134 +1117,6 @@ function makeLiveRepository(): IrohRepositoryShape { }); }), - reserveRelayIssuance: (input) => repositoryEffect("reserve_relay_issuance", async () => { - return await cloudDb().transaction(async (tx) => { - await assertIrohUserMutationAllowed(tx, input.userId); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:relay:${input.userId}`}, 0))`); - const [binding] = await tx - .select() - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.id, input.bindingId), - eq(irohEndpointBindings.userId, input.userId), - isNull(irohEndpointBindings.revokedAt), - )) - .for("update") - .limit(1); - if (!binding) throw new IrohNotFoundError({ resource: "binding" }); - if (binding.clientNamespace !== (input.clientNamespace ?? "legacy")) { - throw new IrohNotFoundError({ resource: "binding" }); - } - - await tx - .update(irohEndpointBindings) - .set({ lastSeenAt: input.now, updatedAt: input.now }) - .where(eq(irohEndpointBindings.id, binding.id)); - - const reservationCutoff = new Date( - input.now.getTime() - IROH_RELAY_RESERVATION_LEASE_MS, - ); - await tx - .update(irohRelayTokenIssuances) - .set({ - status: "expired", - completedAt: input.now, - failureCode: "reservation_expired", - }) - .where(and( - eq(irohRelayTokenIssuances.userId, input.userId), - eq(irohRelayTokenIssuances.status, "pending"), - lte(irohRelayTokenIssuances.requestedAt, reservationCutoff), - )); - - const [issuance] = await tx - .insert(irohRelayTokenIssuances) - .values({ - userId: input.userId, - bindingId: binding.id, - endpointIdHash: sha256(binding.endpointId), - status: "pending", - requestedAt: input.now, - }) - .returning({ id: irohRelayTokenIssuances.id }); - if (!issuance) throw new Error("relay issuance insert returned no row"); - return { issuanceId: issuance.id, binding }; - }); - }), - - completeRelayIssuance: (input) => repositoryEffect("complete_relay_issuance", async () => { - return await cloudDb().transaction(async (tx) => { - await assertIrohUserMutationAllowed(tx, input.userId); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); - const [issuance] = await tx - .select() - .from(irohRelayTokenIssuances) - .where(and( - eq(irohRelayTokenIssuances.id, input.issuanceId), - eq(irohRelayTokenIssuances.userId, input.userId), - eq(irohRelayTokenIssuances.bindingId, input.bindingId), - eq(irohRelayTokenIssuances.status, "pending"), - )) - .for("update") - .limit(1); - if (!issuance) return false; - const [binding] = await tx - .select({ endpointId: irohEndpointBindings.endpointId }) - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.id, input.bindingId), - eq(irohEndpointBindings.userId, input.userId), - isNull(irohEndpointBindings.revokedAt), - )) - .for("update") - .limit(1); - if ( - !binding || - binding.endpointId !== input.endpointId || - issuance.endpointIdHash !== sha256(input.endpointId) - ) { - await tx - .update(irohRelayTokenIssuances) - .set({ - status: "failed", - completedAt: input.completedAt, - failureCode: "binding_inactive_after_mint", - }) - .where(eq(irohRelayTokenIssuances.id, input.issuanceId)); - return false; - } - const completed = await tx - .update(irohRelayTokenIssuances) - .set({ - status: "succeeded", - tokenHash: input.tokenHash, - completedAt: input.completedAt, - expiresAt: input.expiresAt, - failureCode: null, - }) - .where(and( - eq(irohRelayTokenIssuances.id, input.issuanceId), - eq(irohRelayTokenIssuances.status, "pending"), - )) - .returning({ id: irohRelayTokenIssuances.id }); - return completed.length === 1; - }); - }), - - failRelayIssuance: (input) => repositoryEffect("fail_relay_issuance", async () => { - await cloudDb().transaction(async (tx) => { - await assertIrohUserMutationAllowed(tx, input.userId); - await tx - .update(irohRelayTokenIssuances) - .set({ status: "failed", completedAt: input.completedAt, failureCode: input.failureCode.slice(0, 64) }) - .where(and( - eq(irohRelayTokenIssuances.id, input.issuanceId), - eq(irohRelayTokenIssuances.userId, input.userId), - eq(irohRelayTokenIssuances.status, "pending"), - )); - }); - }), }; } @@ -1298,6 +1142,8 @@ async function revokeActiveBindings( directPortV6: null, pathHints: [], pathHintsNextExpiry: null, + relayAttachedUrl: null, + relayAttachReportedAt: null, updatedAt: input.now, }) .where(and( @@ -1424,6 +1270,7 @@ async function drainIrohRetention(input: { path_hints_next_expiry is not null or direct_port_v4 is not null or direct_port_v6 is not null + or relay_attached_url is not null ) order by revoked_at, id limit ${limit} @@ -1434,6 +1281,8 @@ async function drainIrohRetention(input: { path_hints_next_expiry = null, direct_port_v4 = null, direct_port_v6 = null, + relay_attached_url = null, + relay_attach_reported_at = null, updated_at = ${nowIso}::timestamptz from candidates where binding.id = candidates.id @@ -1735,11 +1584,10 @@ function repositoryEffect( function isDomainError(error: unknown): error is | IrohForbiddenError | IrohNotFoundError - | IrohConflictError - | IrohQuotaExceededError { + | IrohConflictError { const tag = (error as { _tag?: unknown } | null)?._tag; return tag === "IrohForbiddenError" || tag === "IrohNotFoundError" || - tag === "IrohConflictError" || tag === "IrohQuotaExceededError"; + tag === "IrohConflictError"; } function sanitizedDatabaseCause(cause: unknown): unknown { diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index be8a16491398..d540a090c8dc 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -23,8 +23,7 @@ export type IrohRouteOperation = | "discover" | "endpoint_attestation" | "revoke" - | "pair_grant" - | "relay_token"; + | "pair_grant"; type RouteDependencies = { readonly verify?: typeof verifyRequest; @@ -221,8 +220,6 @@ function invoke( return broker.revoke(userId, body, undefined, clientNamespace, bindingProof); case "pair_grant": return broker.issuePairGrant(userId, body, undefined, clientNamespace, bindingProof); - case "relay_token": - return broker.issueRelayToken(userId, body, undefined, clientNamespace, bindingProof); } } @@ -353,17 +350,6 @@ function expectedErrorResponse(error: ReturnType & obj if (tag === "IrohConflictError") { return jsonResponse({ error: (error as { code: string }).code }, 409); } - if (tag === "IrohQuotaExceededError") { - const quota = error as { code: string; retryAfterSeconds: number }; - return irohJsonResponse( - { error: quota.code, retry_after_seconds: quota.retryAfterSeconds }, - 429, - { "retry-after": String(quota.retryAfterSeconds) }, - ); - } - if (tag === "IrohConfigurationError" || tag === "IrohRelayMintError") { - return jsonResponse({ error: "iroh_service_unavailable" }, 503); - } return jsonResponse({ error: "iroh_service_unavailable" }, 503); } diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 13cdc74166fc..118f43868b89 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -42,8 +42,6 @@ import { IROH_ENDPOINT_ATTESTATION_VERSION, IROH_PAIR_GRANT_LIFETIME_SECONDS, IROH_PAIR_SCOPE, - IROH_RELAY_TOKEN_LIFETIME_SECONDS, - IROH_RELAY_TOKEN_REFRESH_SECONDS, assertChallengeMatchesPayload, decodeRegistrationPayload, parseBindingIdBody, @@ -52,7 +50,6 @@ import { parseIrohPathHint, parsePairGrantRequest, parseRegisterRequest, - sha256, type IrohPathHint, } from "./model"; import { canIOSBindingUseMac } from "./buildCompatibility"; @@ -67,11 +64,6 @@ import { legacyIrohDiscoveryRequest, parseIrohDiscoveryRequest, } from "./discoveryPagination"; -import { - IrohRelayMinter, - IrohRelayMinterLive, - type IrohRelayMinterShape, -} from "./relayMinter"; import { defaultRelayPreference, type RelayPreference, @@ -84,6 +76,7 @@ import { import { MANAGED_RELAY_URLS, accountPrivateIrohPathHints, + isPublishableAttachedRelayURL, } from "./publicationPolicy"; import { discoveryScopeMatchesRegistration, @@ -145,13 +138,6 @@ export type IrohTrustBrokerShape = { clientNamespace?: string, bindingProof?: IrohBindingRequestProof, ) => Effect.Effect; - readonly issueRelayToken: ( - userId: string, - raw: unknown, - now?: Date, - clientNamespace?: string, - bindingProof?: IrohBindingRequestProof, - ) => Effect.Effect; }; export class IrohTrustBroker extends Context.Tag("cmux/IrohTrustBroker")< @@ -161,7 +147,6 @@ export class IrohTrustBroker extends Context.Tag("cmux/IrohTrustBroker")< export function makeIrohTrustBroker( repository: IrohRepositoryShape, - relayMinter: IrohRelayMinterShape, config: IrohTrustBrokerConfigShape, relayPreferences: Pick = { getPreference: () => Effect.succeed({ @@ -210,73 +195,6 @@ export function makeIrohTrustBroker( return binding; }); - const issueRelayTokenForBinding = ( - userId: string, - binding: IrohBindingRecord, - now: Date, - ): Effect.Effect => Effect.gen(function* () { - const reservation = yield* repository.reserveRelayIssuance({ - userId, - bindingId: binding.id, - clientNamespace: binding.clientNamespace, - now, - }); - const minted = yield* relayMinter.mint({ - endpointId: reservation.binding.endpointId, - lifetimeSeconds: IROH_RELAY_TOKEN_LIFETIME_SECONDS, - now, - }).pipe( - Effect.matchEffect({ - onFailure: (error) => repository.failRelayIssuance({ - userId, - issuanceId: reservation.issuanceId, - completedAt: new Date(), - failureCode: error._tag === "IrohRelayMintError" ? error.code : "not_configured", - }).pipe( - Effect.catchAll(() => Effect.void), - Effect.flatMap(() => Effect.fail(error)), - ), - onSuccess: Effect.succeed, - }), - ); - const completedAt = new Date(); - const completed = yield* repository.completeRelayIssuance({ - userId, - issuanceId: reservation.issuanceId, - bindingId: reservation.binding.id, - endpointId: reservation.binding.endpointId, - tokenHash: sha256(minted.token), - completedAt, - expiresAt: minted.expiresAt, - }); - if (!completed) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); - return { - token: minted.token, - expires_at: minted.expiresAt.toISOString(), - refresh_after: new Date(now.getTime() + IROH_RELAY_TOKEN_REFRESH_SECONDS * 1_000).toISOString(), - relay_fleet: MANAGED_RELAY_URLS, - }; - }); - - const issueRelayToken = ( - userId: string, - raw: unknown, - now = new Date(), - clientNamespace = "legacy", - bindingProof?: IrohBindingRequestProof, - ): Effect.Effect => Effect.gen(function* () { - const { bindingId } = yield* parseEffect(() => parseBindingIdBody(raw)); - const caller = yield* authorizeBinding(userId, bindingProof, clientNamespace, now); - if (caller && caller.id !== bindingId) { - return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); - } - const binding = caller ?? (yield* repository.findActiveBindings(userId, [bindingId]))[0]; - if (!binding || (!caller && binding.clientNamespace !== "legacy")) { - return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); - } - return yield* issueRelayTokenForBinding(userId, binding, now); - }); - const discover = ( userId: string, now = new Date(), @@ -496,18 +414,13 @@ export function makeIrohTrustBroker( now, }); - // New registration is already committed before relay minting starts. - // Refreshes keep their existing credential and use the dedicated relay - // route when it expires, so path-hint churn cannot consume mint quotas. + // Registration never mints a relay credential: relay admission is the + // relay's allow hook against the proven endpoint key, so no client + // credential exists at all. "unavailable" preserves the response shape + // the pre-registry bootstrap produced when the removed n0-hosted minter + // was unconfigured, which production always was. const relay = registration.created - ? yield* issueRelayTokenForBinding( - userId, - registration.binding, - now, - ).pipe( - Effect.map((value) => ({ status: "issued" as const, ...value as object })), - Effect.catchAll(() => Effect.succeed({ status: "unavailable" as const })), - ) + ? { status: "unavailable" as const } : { status: "not_requested" as const }; const discovery = request.discoveryScope ? (yield* discoverScoped( @@ -722,8 +635,6 @@ export function makeIrohTrustBroker( grant_verification_keys: verificationKeys.keySet, }; }), - - issueRelayToken, }; } @@ -732,23 +643,17 @@ export const IrohTrustBrokerLive = Layer.effect( Effect.gen(function* () { return makeIrohTrustBroker( yield* IrohRepository, - yield* IrohRelayMinter, yield* IrohTrustBrokerConfig, yield* RelayRepository, ); }), ); -const IrohRelayMinterWithConfig = IrohRelayMinterLive.pipe( - Layer.provide(IrohTrustBrokerConfigLive), -); - export const IrohTrustBrokerRuntime = IrohTrustBrokerLive.pipe( Layer.provide(Layer.mergeAll( IrohRepositoryLive, RelayRepositoryLive, IrohTrustBrokerConfigLive, - IrohRelayMinterWithConfig, )), ); @@ -788,15 +693,84 @@ function publicBinding( ...(binding.directPortV6 === null ? {} : { ipv6: binding.directPortV6 }), }, }), - path_hints: accountPrivateIrohPathHints(binding.pathHints.flatMap((hint): IrohPathHint[] => { + path_hints: bindingPathHints(binding, now, savedCustomRelayURLs), + last_seen_at: binding.lastSeenAt.toISOString(), + }; +} + +/** + * Serves the relay-reported attach route ahead of endpoint-published hints. + * + * The fleet reports attach/detach per admitted connection (cmux-relay + * attach reporting), so `relayAttachedUrl` is live server-side truth and the + * phone learns "reachable via relay Y" without the Mac's client-side + * post-attach republish. The synthesized hint reuses the standard path-hint + * shape so existing clients dial it unchanged; the client-published hint for + * the same URL is dropped as redundant, and every other client hint stays a + * fallback while pre-attach-reporting fleet relays remain deployed. + */ +function bindingPathHints( + binding: IrohBindingRecord, + now: Date, + savedCustomRelayURLs: ReadonlySet, +): IrohPathHint[] { + const clientHints = accountPrivateIrohPathHints( + binding.pathHints.flatMap((hint): IrohPathHint[] => { try { return [parseIrohPathHint(hint, now)]; } catch { return []; } - }), savedCustomRelayURLs), - last_seen_at: binding.lastSeenAt.toISOString(), + }), + savedCustomRelayURLs, + ); + const attachedURL = binding.relayAttachedUrl; + if ( + attachedURL === null || + !isPublishableAttachedRelayURL(attachedURL, savedCustomRelayURLs) || + !attachmentCorroborated(binding, now) + ) { + return clientHints; + } + const serverHint: IrohPathHint = { + kind: "relay_url", + value: attachedURL, + source: "native", + privacy_scope: "public_internet", + // Attachment is current as of this read; clients bound hint freshness to + // observed_at + 1h, and every discovery read re-serves the live state. + observed_at: now.toISOString(), + expires_at: new Date(now.getTime() + SERVER_RELAY_HINT_TTL_MS).toISOString(), }; + return [ + serverHint, + ...clientHints.filter((hint) => + !(hint.kind === "relay_url" && hint.value === attachedURL)), + ]; +} + +/** Half the model's 1h hint-lifetime cap; refreshed by every discovery read. */ +const SERVER_RELAY_HINT_TTL_MS = 30 * 60 * 1_000; + +/** + * Detach reports are fire-and-forget, so a relay that dies together with its + * report leaves `relayAttachedUrl` behind; without a liveness bound that dead + * route would be re-served as fresh forever. An attachment is served only + * while some live evidence is younger than this window: the attach report + * itself, or the binding's `lastSeenAt` (a live Mac re-registers at least + * hourly to keep its ≤1h path hints and binding freshness lease current, + * and a Mac that outlives its relay reattaches elsewhere, which overwrites + * the URL). A Mac that goes dark with its relay stops refreshing both, so + * the stale route ages out within this window. + */ +const SERVER_RELAY_ATTACH_LIVENESS_MS = 60 * 60 * 1_000; + +function attachmentCorroborated(binding: IrohBindingRecord, now: Date): boolean { + const freshestEvidence = Math.max( + binding.relayAttachReportedAt?.getTime() ?? 0, + binding.lastSeenAt.getTime(), + ); + return now.getTime() - freshestEvidence <= SERVER_RELAY_ATTACH_LIVENESS_MS; } function customRelayURLs(preference: RelayPreference): ReadonlySet { @@ -834,4 +808,3 @@ function bindingPlatform(binding: IrohBindingRecord): "mac" | "ios" { // Stack bearer authentication alone is never sufficient to mutate path hints. // Until the dedicated endpoint-signed monotonic update route lands, clients // refresh watch_addr output only through a new signed registration challenge. -export const IROH_SIGNED_PATH_HINT_UPDATE_FOLLOWUP = "endpoint-signed-monotonic-watch-addr-update-v1"; diff --git a/web/services/relay/allow.ts b/web/services/relay/allow.ts index a5248ceea3c1..e49fbcb3e2af 100644 --- a/web/services/relay/allow.ts +++ b/web/services/relay/allow.ts @@ -6,22 +6,15 @@ // trustworthy; this side only decides whether that endpoint is admitted. // // The admission lookup deliberately does NOT borrow the shared cloudDb -// client: its pool checkout and connection phases have no deadline there, so -// a stalled operation could neither be cancelled nor be counted on to settle. -// Instead the admission path owns a dedicated client sized to its concurrency -// cap with a hard bound on every phase (connect, checkout, execution, plus a -// client-side cancel), so every admission operation settles within a known -// bound and the concurrency slots — released strictly at settlement — bound -// retained work without ever staying saturated after an outage heals. +// client: it runs on the deadline-bounded per-hook client from ./hookDb, so +// every admission operation settles within a known bound and the concurrency +// slots — released strictly at settlement — bound retained work without ever +// staying saturated after an outage heals. import { createHmac, timingSafeEqual } from "node:crypto"; -import { attachDatabasePool } from "@vercel/functions"; -import type { Pool } from "pg"; -import postgres, { type Sql } from "postgres"; -import { createAwsRdsIamPool } from "../../db/client"; -import { cloudDbConfig, cloudDbConfigKey } from "../../db/config"; import { isBlockingAccountDeletionTombstone } from "../account/deletionLock"; +import { closeRelayHookDbClientForTests, relayHookDbClient } from "./hookDb"; export const RELAY_ALLOW_SIGNATURE_HEADER = "x-cmux-relay-allow-signature"; @@ -53,10 +46,6 @@ export const RELAY_ALLOW_STATEMENT_TIMEOUT_MS = 2_500; */ export const RELAY_ALLOW_LOOKUP_SETTLE_MS = 4_000; -/** Connection-establishment (and, for pg, checkout-wait) deadline. */ -const CONNECT_TIMEOUT_MS = 5_000; -const IDLE_TIMEOUT_SECONDS = 60; - export class RelayAllowAdmissionSaturatedError extends Error { constructor() { super("relay allow admission concurrency saturated"); @@ -150,91 +139,20 @@ const ADMISSION_SQL = ` limit 1 `; -type AdmissionClientState = { - readonly key: string; - readonly lookup: (endpointId: string) => Promise; - readonly close: () => Promise; -}; - -const globalForAdmission = globalThis as typeof globalThis & { - __cmuxRelayAllowAdmission?: AdmissionClientState; -}; - -function admissionClient(): AdmissionClientState { - const config = cloudDbConfig(); - const key = cloudDbConfigKey(config); - const cached = globalForAdmission.__cmuxRelayAllowAdmission; - if (cached?.key === key) return cached; - if (cached) { - // The database config rotated within this runtime: drop the stale client - // and close it so its pool is not retained alongside the replacement. - // In-flight lookups hold their own reference and settle under their phase - // deadlines; close() (pool.end / sql.end) waits for them, so this cannot - // interrupt an admission already running. - globalForAdmission.__cmuxRelayAllowAdmission = undefined; - void cached.close().catch(() => { - // Best-effort teardown; the replacement client is unaffected. - }); - } +const ADMISSION_HOOK = "relay-allow"; - let state: AdmissionClientState; - if (config.driver === "aws-rds-iam") { - const pool: Pool = createAwsRdsIamPool(config, { - max: RELAY_ALLOW_MAX_CONCURRENT_ADMISSIONS, - // Bounds checkout waits as well as connection establishment. - connectionTimeoutMillis: CONNECT_TIMEOUT_MS, - idleTimeoutMillis: IDLE_TIMEOUT_SECONDS * 1_000, - statement_timeout: RELAY_ALLOW_STATEMENT_TIMEOUT_MS, - query_timeout: RELAY_ALLOW_LOOKUP_SETTLE_MS, - }); - attachDatabasePool(pool); - state = { - key, - lookup: async (endpointId) => { - const result = await pool.query(ADMISSION_SQL, [endpointId]); - return result.rows[0] ?? null; - }, - close: () => pool.end(), - }; - } else { - const sql: Sql = postgres(config.url, { - max: RELAY_ALLOW_MAX_CONCURRENT_ADMISSIONS, - prepare: false, - connect_timeout: Math.ceil(CONNECT_TIMEOUT_MS / 1_000), - idle_timeout: IDLE_TIMEOUT_SECONDS, - connection: { statement_timeout: RELAY_ALLOW_STATEMENT_TIMEOUT_MS }, - }); - state = { - key, - lookup: async (endpointId) => { - const query = sql.unsafe(ADMISSION_SQL, [endpointId]); - // cancel() rejects the query whether still queued or executing, so - // the operation settles even through a pool or network stall. - const settleBound = setTimeout(() => { - try { - query.cancel(); - } catch { - // Cancellation is best-effort; the statement timeout remains. - } - }, RELAY_ALLOW_LOOKUP_SETTLE_MS); - try { - const rows = await query; - return rows[0] ?? null; - } finally { - clearTimeout(settleBound); - } - }, - close: () => sql.end(), - }; - } - globalForAdmission.__cmuxRelayAllowAdmission = state; - return state; +async function admissionLookup(endpointId: string): Promise { + const client = relayHookDbClient(ADMISSION_HOOK, { + maxConnections: RELAY_ALLOW_MAX_CONCURRENT_ADMISSIONS, + statementTimeoutMs: RELAY_ALLOW_STATEMENT_TIMEOUT_MS, + settleMs: RELAY_ALLOW_LOOKUP_SETTLE_MS, + }); + const rows = await client.query(ADMISSION_SQL, [endpointId]); + return (rows[0] as AdmissionRow | undefined) ?? null; } export async function closeRelayAllowAdmissionClientForTests(): Promise { - const state = globalForAdmission.__cmuxRelayAllowAdmission; - globalForAdmission.__cmuxRelayAllowAdmission = undefined; - await state?.close(); + await closeRelayHookDbClientForTests(ADMISSION_HOOK); } /** @@ -247,7 +165,7 @@ export async function relayAllowAdmission( endpointId: string, ): Promise { return await withRelayAllowAdmissionSlot(async () => { - const row = await admissionClient().lookup(endpointId); + const row = await admissionLookup(endpointId); if (!row) return "deny" as const; if (tombstoneBlocks(row)) return "deny" as const; return "allow" as const; diff --git a/web/services/relay/hookDb.ts b/web/services/relay/hookDb.ts new file mode 100644 index 000000000000..ea445f265282 --- /dev/null +++ b/web/services/relay/hookDb.ts @@ -0,0 +1,142 @@ +// Dedicated deadline-bounded Postgres access for relay fleet hooks +// (/api/relay/allow, /api/relay/report). +// +// These hooks deliberately do NOT borrow the shared cloudDb client: its pool +// checkout and connection phases have no deadline there, so a stalled +// operation could neither be cancelled nor be counted on to settle. Each hook +// instead owns a client sized to its concurrency cap with a hard bound on +// every phase (connect, checkout, execution, plus a client-side cancel), so +// every hook operation settles within a known bound and the hook's +// concurrency slots — released strictly at settlement — bound retained work +// without ever staying saturated after an outage heals. +// +// Clients are cached per hook name so each hook keeps its own pool: report +// ingestion load can never queue behind (or starve) connection admissions. + +import { attachDatabasePool } from "@vercel/functions"; +import type { Pool } from "pg"; +import postgres, { type Sql } from "postgres"; + +import { createAwsRdsIamPool } from "../../db/client"; +import { cloudDbConfig, cloudDbConfigKey } from "../../db/config"; + +/** Connection-establishment (and, for pg, checkout-wait) deadline. */ +const CONNECT_TIMEOUT_MS = 5_000; +const IDLE_TIMEOUT_SECONDS = 60; + +export type RelayHookDbBounds = { + /** + * Pool size; pair it with the hook's concurrency cap so no hook operation + * ever queues inside the driver. + */ + readonly maxConnections: number; + /** + * Server-side statement_timeout, set as a session parameter on every + * connection: Postgres cancels an executing statement and frees the + * connection. + */ + readonly statementTimeoutMs: number; + /** + * Client-side settle bound. postgres.js: a timer calls query.cancel(), + * which rejects the query whether it is still queued or already executing. + * pg: the pool's query_timeout enforces the same bound. Keep it above the + * statement timeout so the server usually cancels first. + */ + readonly settleMs: number; +}; + +export type RelayHookDbClient = { + readonly query: ( + text: string, + params: readonly unknown[], + ) => Promise[]>; + readonly close: () => Promise; +}; + +type CachedClient = { + readonly configKey: string; + readonly client: RelayHookDbClient; +}; + +const globalForHooks = globalThis as typeof globalThis & { + __cmuxRelayHookDbClients?: Map; +}; + +export function relayHookDbClient( + hook: string, + bounds: RelayHookDbBounds, +): RelayHookDbClient { + const config = cloudDbConfig(); + const configKey = cloudDbConfigKey(config); + const cache = (globalForHooks.__cmuxRelayHookDbClients ??= new Map()); + const cached = cache.get(hook); + if (cached?.configKey === configKey) return cached.client; + if (cached) { + // The database config rotated within this runtime: drop the stale client + // and close it so its pool is not retained alongside the replacement. + // In-flight operations hold their own reference and settle under their + // phase deadlines; close() (pool.end / sql.end) waits for them, so this + // cannot interrupt an operation already running. + cache.delete(hook); + void cached.client.close().catch(() => { + // Best-effort teardown; the replacement client is unaffected. + }); + } + + let client: RelayHookDbClient; + if (config.driver === "aws-rds-iam") { + const pool: Pool = createAwsRdsIamPool(config, { + max: bounds.maxConnections, + // Bounds checkout waits as well as connection establishment. + connectionTimeoutMillis: CONNECT_TIMEOUT_MS, + idleTimeoutMillis: IDLE_TIMEOUT_SECONDS * 1_000, + statement_timeout: bounds.statementTimeoutMs, + query_timeout: bounds.settleMs, + }); + attachDatabasePool(pool); + client = { + query: async (text, params) => { + const result = await pool.query(text, params as unknown[]); + return result.rows as readonly Record[]; + }, + close: () => pool.end(), + }; + } else { + const sql: Sql = postgres(config.url, { + max: bounds.maxConnections, + prepare: false, + connect_timeout: Math.ceil(CONNECT_TIMEOUT_MS / 1_000), + idle_timeout: IDLE_TIMEOUT_SECONDS, + connection: { statement_timeout: bounds.statementTimeoutMs }, + }); + client = { + query: async (text, params) => { + const query = sql.unsafe(text, params as never[]); + // cancel() rejects the query whether still queued or executing, so + // the operation settles even through a pool or network stall. + const settleBound = setTimeout(() => { + try { + query.cancel(); + } catch { + // Cancellation is best-effort; the statement timeout remains. + } + }, bounds.settleMs); + try { + return (await query) as unknown as readonly Record[]; + } finally { + clearTimeout(settleBound); + } + }, + close: () => sql.end(), + }; + } + cache.set(hook, { configKey, client }); + return client; +} + +export async function closeRelayHookDbClientForTests(hook: string): Promise { + const cache = globalForHooks.__cmuxRelayHookDbClients; + const cached = cache?.get(hook); + cache?.delete(hook); + await cached?.client.close(); +} diff --git a/web/services/relay/http.ts b/web/services/relay/http.ts index fe8121d62d1d..00284b551b6f 100644 --- a/web/services/relay/http.ts +++ b/web/services/relay/http.ts @@ -175,3 +175,65 @@ export function jsonResponse( }, }); } + +export type BoundedBodyResult = + | { readonly ok: true; readonly bytes: Uint8Array } + | { readonly ok: false; readonly response: Response }; + +/** + * Reads a request body under a hard byte cap and a hard read deadline, for + * unauthenticated fleet-hook routes (/api/relay/allow, /api/relay/report). + * The byte cap alone does not stop a slowloris client that trickles (or + * never finishes) a body to occupy the handler; the deadline cancels the + * request stream itself on expiry — real cancellation, not an abandoned + * promise. A missing body resolves to zero bytes, so callers that require a + * body decide their own failure mode. + */ +export async function readBoundedBody( + request: Request, + options: { readonly maxBytes: number; readonly timeoutMs: number }, +): Promise { + const contentLength = request.headers.get("content-length"); + if (contentLength) { + const parsed = Number(contentLength); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > options.maxBytes) { + return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; + } + } + const reader = request.body?.getReader(); + if (!reader) return { ok: true, bytes: new Uint8Array() }; + const chunks: Uint8Array[] = []; + let total = 0; + let timedOut = false; + // Cancelling the reader on expiry resolves the pending read() and releases + // the underlying stream. + const timer = setTimeout(() => { + timedOut = true; + void reader.cancel().catch(() => undefined); + }, options.timeoutMs); + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > options.maxBytes) { + await reader.cancel(); + return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; + } + chunks.push(next.value); + } + } catch { + if (!timedOut) { + return { ok: false, response: jsonResponse({ error: "invalid_body" }, 400) }; + } + } finally { + clearTimeout(timer); + } + if (timedOut) { + return { ok: false, response: jsonResponse({ error: "request_read_timeout" }, 408) }; + } + return { + ok: true, + bytes: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total), + }; +} diff --git a/web/services/relay/report.ts b/web/services/relay/report.ts new file mode 100644 index 000000000000..d166a993f4de --- /dev/null +++ b/web/services/relay/report.ts @@ -0,0 +1,338 @@ +// Backend half of the relay fleet's attach/detach reporting (cmux-relay +// `Reporter`, manaflow-ai/cmux-relay PR #9). On every admitted connect the +// relay fire-and-forgets `POST CMUX_RELAY_REPORT_URL` with the JSON body +// `{endpointId, event: "attach"|"detach", relayId, ts}`, signed exactly like +// allow-hook calls: unpadded base64url HMAC-SHA256 over the raw body bytes in +// the same `x-cmux-relay-allow-signature` header, same shared secret +// (CMUX_RELAY_ALLOW_HMAC_SECRET_B64). `relayId` is the relay's public +// hostname (its `--hostname` flag, e.g. `usc1.relay.cmux.dev`); `ts` is the +// relay-side event time in unix milliseconds. +// +// Applying a report publishes "endpoint X is reachable via relay Y" into the +// registry (`iroh_endpoint_bindings.relay_attached_url`), which discovery +// serves to the account's other devices as a server-observed relay hint — +// replacing the Mac's client-side post-attach republish. +// +// Trust decision: the HMAC proves a fleet relay (holder of the shared +// secret) sent the report, but `relayId` inside the body is self-declared, +// so a single compromised relay — or a leaked secret — could otherwise +// publish an attacker-controlled relay URL to every phone on any account. +// An ATTACH is therefore only applied when its hostname resolves to a relay +// the account is already allowed to dial: an exact managed-catalog URL, or a +// custom relay the account has saved (matched by hostname, publishing the +// saved URL verbatim). Every other attach is rejected (`untrusted_relay`), +// which also keeps dev relays (`cmux-relay-dev`) out of production state. +// A DETACH clears state instead of publishing it, so it is matched against +// the stored URL by hostname without the trust lookup — see +// `applyRelayAttachReport`. +// Debug/test fleets get trusted the same way: their relay must be in the +// catalog the deployment was built with, or saved as that account's custom +// relay; the HMAC alone is deliberately NOT sufficient. +// +// Ordering: reports are fire-and-forget and can arrive out of order, so each +// applied report records its relay-side event timestamp and older events are +// dropped (`superseded`). Attach wins a timestamp tie (make-before-break +// reconnects admit the new connection before the old one closes). Known +// residual: the report body carries no connection id, so a same-relay +// reconnect whose old-connection detach lands after the new-connection +// attach (with a later relay-side ts) unpublishes the route until the next +// attach event; the client republish fallback covers that window, and fixing +// it properly needs a connection id in the relay's report body. +// +// Unlike /api/relay/allow this path does not consult account-deletion +// tombstones: deletion revokes bindings (which hides them from discovery and +// denies relay admission), so attach state on a to-be-deleted account is +// unreachable either way. + +import { RELAY_ALLOW_SIGNATURE_HEADER } from "./allow"; +import { MANAGED_IROH_RELAY_CATALOG } from "./generated/managedRelayCatalog"; +import { closeRelayHookDbClientForTests, relayHookDbClient } from "./hookDb"; + +/** Same header, same signing scheme, same secret as the allow hook. */ +export const RELAY_REPORT_SIGNATURE_HEADER = RELAY_ALLOW_SIGNATURE_HEADER; + +/** + * Hard cap on concurrently running report applications per runtime instance, + * and the size of the dedicated report pool (separate from the admission + * pool, so report bursts can never starve connection admissions). A + * saturated instance answers 503; the relay treats any failure as + * best-effort and the next attach/detach event self-heals the state. + */ +export const RELAY_REPORT_MAX_CONCURRENT = 16; + +export const RELAY_REPORT_STATEMENT_TIMEOUT_MS = 2_500; +export const RELAY_REPORT_SETTLE_MS = 4_000; + +/** + * Reject event timestamps this far ahead of server time. Without the bound a + * relay with a runaway clock would plant a far-future `relay_attach_reported_at` + * that blocks every later (correctly timed) report for the endpoint. + */ +export const RELAY_REPORT_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; + +/** + * Reject event timestamps this far behind server time. The Reporter sends + * each event once, immediately, with a 3s HTTP timeout and no retry, so a + * legitimate report is at most seconds old; the allowance is for relay clock + * drift. Without the bound, a captured signed attach (HMAC has no nonce) + * could be replayed much later into an empty attachment slot and would then + * be re-served as current reachability until the liveness window expired. + */ +export const RELAY_REPORT_MAX_PAST_SKEW_MS = 15 * 60 * 1_000; + +const ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; +// RFC 1123 hostname labels, lowercase; total length bounded below. +const RELAY_HOSTNAME_RE = + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/; +const MAX_RELAY_HOSTNAME_LENGTH = 253; + +export class RelayReportSaturatedError extends Error { + constructor() { + super("relay report concurrency saturated"); + this.name = "RelayReportSaturatedError"; + } +} + +let inFlightReports = 0; + +/** Runs one report application under the concurrency cap; rejects when saturated. */ +export async function withRelayReportSlot( + operation: () => Promise, +): Promise { + if (inFlightReports >= RELAY_REPORT_MAX_CONCURRENT) { + throw new RelayReportSaturatedError(); + } + inFlightReports += 1; + try { + return await operation(); + } finally { + inFlightReports -= 1; + } +} + +export type RelayAttachReport = { + readonly endpointId: string; + readonly event: "attach" | "detach"; + readonly relayId: string; + /** Relay-side event time (body `ts`, unix milliseconds). */ + readonly reportedAt: Date; +}; + +export type RelayReportParseError = + | "invalid_report_body" + | "invalid_endpoint_id" + | "invalid_report_event" + | "invalid_relay_id" + | "invalid_report_time"; + +export type RelayReportParseResult = + | { readonly ok: true; readonly report: RelayAttachReport } + | { readonly ok: false; readonly error: RelayReportParseError }; + +export function parseRelayAttachReport( + value: unknown, + now: Date, +): RelayReportParseResult { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, error: "invalid_report_body" }; + } + const record = value as Record; + const allowed = new Set(["endpointId", "event", "relayId", "ts"]); + if (Object.keys(record).some((key) => !allowed.has(key))) { + return { ok: false, error: "invalid_report_body" }; + } + + const endpointId = typeof record.endpointId === "string" + ? record.endpointId.trim().toLowerCase() + : ""; + if (!ENDPOINT_ID_RE.test(endpointId)) { + return { ok: false, error: "invalid_endpoint_id" }; + } + + const event = record.event; + if (event !== "attach" && event !== "detach") { + return { ok: false, error: "invalid_report_event" }; + } + + const relayId = typeof record.relayId === "string" + ? record.relayId.trim().toLowerCase() + : ""; + if ( + relayId.length === 0 || + relayId.length > MAX_RELAY_HOSTNAME_LENGTH || + !RELAY_HOSTNAME_RE.test(relayId) + ) { + return { ok: false, error: "invalid_relay_id" }; + } + + const ts = record.ts; + if ( + typeof ts !== "number" || + !Number.isSafeInteger(ts) || + ts <= 0 || + ts > now.getTime() + RELAY_REPORT_MAX_FUTURE_SKEW_MS || + ts < now.getTime() - RELAY_REPORT_MAX_PAST_SKEW_MS + ) { + return { ok: false, error: "invalid_report_time" }; + } + + return { + ok: true, + report: { endpointId, event, relayId, reportedAt: new Date(ts) }, + }; +} + +/** + * Resolves a reported relay hostname to the exact URL the registry may + * publish: the managed catalog first, then the account's saved custom relays + * (their saved URL verbatim, so ports survive). Returns null for hostnames + * the account is not allowed to dial. + */ +export function publishableRelayURLForHostname( + relayId: string, + savedCustomRelayURLs: readonly string[], +): string | null { + for (const relay of MANAGED_IROH_RELAY_CATALOG.relays) { + if (urlHostname(relay.url) === relayId) return relay.url; + } + // Deterministic pick if several saved relays share one hostname. + for (const saved of [...savedCustomRelayURLs].sort()) { + if (urlHostname(saved) === relayId) return saved; + } + return null; +} + +function urlHostname(value: string): string | null { + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return null; + } +} + +export type RelayReportOutcome = + | "applied" + | "superseded" + | "unknown_endpoint" + | "untrusted_relay"; + +// The active binding for the endpoint (with its current attachment) plus +// that account's saved custom relays (preferences are keyed by the same +// Stack user id bindings carry). The partial unique index +// `iroh_endpoint_bindings_active_endpoint_unique` guarantees at most one row. +const REPORT_CONTEXT_SQL = ` + select + binding.user_id as "userId", + binding.relay_attached_url as "attachedUrl", + pref.custom_relays as "customRelays" + from iroh_endpoint_bindings binding + left join iroh_relay_preferences pref + on pref.account_id = binding.user_id + where binding.endpoint_id = $1 + and binding.revoked_at is null + limit 1 +`; + +// Ordering guards: an attach applies unless a strictly newer event was +// already applied (attach wins ties); a detach applies only against the +// exact URL it detached from and only when strictly newer, so a late detach +// from an old relay can never clear a newer attachment elsewhere. +const APPLY_ATTACH_SQL = ` + update iroh_endpoint_bindings + set relay_attached_url = $2, + relay_attach_reported_at = $3::timestamptz + where endpoint_id = $1 + and revoked_at is null + and (relay_attach_reported_at is null or relay_attach_reported_at <= $3::timestamptz) + returning id +`; + +const APPLY_DETACH_SQL = ` + update iroh_endpoint_bindings + set relay_attached_url = null, + relay_attach_reported_at = $3::timestamptz + where endpoint_id = $1 + and revoked_at is null + and relay_attached_url = $2 + and relay_attach_reported_at < $3::timestamptz + returning id +`; + +const REPORT_HOOK = "relay-report"; + +function reportClient() { + return relayHookDbClient(REPORT_HOOK, { + maxConnections: RELAY_REPORT_MAX_CONCURRENT, + statementTimeoutMs: RELAY_REPORT_STATEMENT_TIMEOUT_MS, + settleMs: RELAY_REPORT_SETTLE_MS, + }); +} + +function savedCustomRelayURLs(customRelays: unknown): string[] { + if (!Array.isArray(customRelays)) return []; + const urls: string[] = []; + for (const relay of customRelays) { + if (relay !== null && typeof relay === "object" && !Array.isArray(relay)) { + const url = (relay as Record).url; + if (typeof url === "string") urls.push(url); + } + } + return urls; +} + +/** + * Applies one verified report to the registry. Both statements run on the + * dedicated deadline-bounded report client under the concurrency cap. + * + * The catalog/saved-set trust rule gates only ATTACH, because only an attach + * publishes a URL. A detach merely clears the stored attachment, so it is + * matched against the STORED URL by hostname and needs no trust resolution: + * a custom relay deleted from the account's preferences must still be able + * to detach cleanly, or its stale attachment would resurface if the same + * relay were ever saved again (a forged clear already requires the shared + * secret and only degrades discovery to the client-published fallback). + */ +export async function applyRelayAttachReport( + report: RelayAttachReport, +): Promise { + return await withRelayReportSlot(async () => { + const client = reportClient(); + const context = (await client.query(REPORT_CONTEXT_SQL, [report.endpointId]))[0]; + if (!context) return "unknown_endpoint" as const; + + let url: string; + if (report.event === "attach") { + const publishable = publishableRelayURLForHostname( + report.relayId, + savedCustomRelayURLs(context.customRelays), + ); + if (publishable === null) return "untrusted_relay" as const; + url = publishable; + } else { + const attachedUrl = typeof context.attachedUrl === "string" + ? context.attachedUrl + : null; + if (attachedUrl === null || urlHostname(attachedUrl) !== report.relayId) { + // Nothing (or a different relay's route) is published; the detach is + // stale relative to the applied event stream. + return "superseded" as const; + } + // Clear exactly what was read; the WHERE below re-checks it so a + // concurrent attach between the two statements survives. + url = attachedUrl; + } + + const applied = await client.query( + report.event === "attach" ? APPLY_ATTACH_SQL : APPLY_DETACH_SQL, + [report.endpointId, url, report.reportedAt.toISOString()], + ); + // Zero rows: an equal-or-newer event already holds the slot, the detach + // target URL no longer matches, or the binding was revoked between the + // two statements. All are stale-report shapes, not failures. + return applied.length > 0 ? ("applied" as const) : ("superseded" as const); + }); +} + +export async function closeRelayReportClientForTests(): Promise { + await closeRelayHookDbClientForTests(REPORT_HOOK); +} diff --git a/web/services/relay/token.ts b/web/services/relay/token.ts deleted file mode 100644 index 2ca1c6cb196d..000000000000 --- a/web/services/relay/token.ts +++ /dev/null @@ -1,133 +0,0 @@ -// Pure token-minting logic for the private cmux iroh relay fleet, kept separate -// from the HTTP route so it is testable without the auth/DB/telemetry graph. -// -// The web API is the token issuer: it holds the Ed25519 PRIVATE signing key -// (`CMUX_RELAY_JWT_PRIVATE_KEY_PEM`); every relay VM holds only the matching -// PUBLIC key and verifies tokens offline. A minted token is a short-TTL EdDSA -// JWT with `iss=cmux`, `aud=cmux-relay`, `sub=`, and a required -// `endpoint_id` binding (so a leaked token cannot be replayed from another key). - -import { createPrivateKey, sign as edSign, type KeyObject } from "node:crypto"; -import { configuredRelayCatalog } from "./catalog"; - -export const RELAY_TOKEN_ISS = "cmux"; -export const RELAY_TOKEN_AUD = "cmux-relay"; -export const RELAY_TOKEN_TTL_SECONDS = 300; // short-lived; the client refreshes -export const RELAY_TOKEN_REFRESH_LEAD_SECONDS = 60; - -export type ManagedRelayCredentialGrant = { - readonly relayUrl: string; - readonly token: string; - readonly expiresAt: number; - readonly refreshAfter: number; - readonly ttlSeconds: number; -}; - -// iroh EndpointId is a 32-byte Ed25519 public key. The cmux relay parses the -// JWT claim with `EndpointId::from_str`, which (in iroh-base 1.0.0-rc.1) accepts -// EXACTLY 64-char lowercase hex OR 52-char RFC 4648 base32 (A-Z2-7, -// case-insensitive; `to_string()` emits hex). z-base-32 is a SEPARATE from_z32 -// API the relay does not use, so it must NOT be accepted here. Anything the -// parser rejects would be a signed-but-useless 200, so fail fast with 400. -// (We lowercase before matching; hex is minted lowercase to satisfy HEXLOWER, -// and the relay uppercases base32 internally.) -const HEX_ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; -// A 52-char RFC 4648 base32 encoding of exactly 32 bytes has 4 trailing zero -// bits, so the final symbol carries 1 data bit + 4 zero bits and can only be -// `a` (0) or `q` (16). Other final symbols have non-zero trailing bits, which -// iroh's BASE32_NOPAD decoder rejects — so require the canonical final symbol. -const BASE32_ENDPOINT_ID_RE = /^[a-z2-7]{51}[aq]$/; - -/** Exact canonical fleet retained for compatibility with older route callers. */ -export function relayUrls(): string[] { - return configuredRelayCatalog().relays.map((relay) => relay.url); -} - -// Note: this checks the exact encoding shape, not that the 32 bytes decode to a -// valid Ed25519 curve point (e.g. 64 `f`s pass here but are not on the curve). -// Full on-curve validation is intentionally left to the relay, which is the -// authoritative validator at connect time: the endpoint_id is the CALLER'S OWN -// iroh public key, so a legitimate client always sends a valid point, and a -// crafted-but-invalid id only yields a token bound to a key nobody holds (the -// relay requires the token's endpoint_id to equal the handshake-authenticated -// key), i.e. self-harm with no replay or availability impact. Adding a curve -// library here to reject a self-defeating input is not worth the dependency. -export function isValidEndpointId(value: string): boolean { - const v = value.toLowerCase(); - return HEX_ENDPOINT_ID_RE.test(v) || BASE32_ENDPOINT_ID_RE.test(v); -} - -// Parse the signing key once and cache it keyed on the PEM value, so -// `createPrivateKey` (not free) runs only when the configured key changes. -let cached: { pem: string; key: KeyObject } | null = null; - -export function relaySigningKey(): KeyObject | null { - const pem = process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; - if (!pem || !pem.includes("BEGIN")) return null; - if (cached && cached.pem === pem) return cached.key; - try { - const key = createPrivateKey(pem); - // The fleet's baked public key is Ed25519; a misconfigured RSA/EC/Ed448 key - // would sign a token no relay can verify. Treat it as unconfigured (-> 503) - // rather than minting an unusable token or throwing at sign time. - if (key.asymmetricKeyType !== "ed25519") return null; - cached = { pem, key }; - return key; - } catch { - return null; - } -} - -function b64url(input: Buffer | string): string { - return Buffer.from(input).toString("base64url"); -} - -/** - * Mint a compact EdDSA (Ed25519) JWT. Ed25519 signs the raw message (no prehash), - * so the digest passed to `sign` is `null`. The output is byte-for-byte what - * `jsonwebtoken`/`jose` produce and what the relay's verifier accepts. - * - * `endpointId` is REQUIRED: every issued token is bound to the caller's iroh - * endpoint key so a leaked token cannot be replayed from a different key. - */ -export function mintRelayToken(params: { - sub: string; - endpointId: string; - key: KeyObject; - nowSeconds: number; -}): { token: string; expiresAt: number } { - const { sub, endpointId, key, nowSeconds } = params; - const expiresAt = nowSeconds + RELAY_TOKEN_TTL_SECONDS; - const header = { alg: "EdDSA", typ: "JWT" }; - const payload: Record = { - iss: RELAY_TOKEN_ISS, - aud: RELAY_TOKEN_AUD, - sub, - iat: nowSeconds, - exp: expiresAt, - endpoint_id: endpointId.toLowerCase(), - }; - const signingInput = `${b64url(JSON.stringify(header))}.${b64url( - JSON.stringify(payload), - )}`; - const signature = edSign(null, Buffer.from(signingInput), key); - return { token: `${signingInput}.${b64url(signature)}`, expiresAt }; -} - -/** Mint URL-keyed grants without coupling clients to a relay provider. */ -export function mintManagedRelayCredentials(params: { - readonly sub: string; - readonly endpointId: string; - readonly relayUrls: readonly string[]; - readonly key: KeyObject; - readonly nowSeconds: number; -}): ManagedRelayCredentialGrant[] { - const minted = mintRelayToken(params); - return params.relayUrls.map((relayUrl) => ({ - relayUrl, - token: minted.token, - expiresAt: minted.expiresAt, - refreshAfter: minted.expiresAt - RELAY_TOKEN_REFRESH_LEAD_SECONDS, - ttlSeconds: RELAY_TOKEN_TTL_SECONDS, - })); -} diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index 12e243610d42..341779e81fe9 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -21,13 +21,9 @@ const requiredIrohProductionEnv = { CMUX_IROH_GRANT_SIGNING_KEY_P8: `-----BEGIN PRIVATE KEY-----\n${"A".repeat(64)}\n-----END PRIVATE KEY-----`, CMUX_IROH_GRANT_SIGNING_KID: "current", CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: "{}", - CMUX_IROH_MINT_URL: "https://iroh-minter.example.com/api/relay-token", - CMUX_IROH_MINT_HMAC_SECRET_B64: Buffer.alloc(32, 0x33).toString("base64"), }; const requiredRelayProductionEnv = { - CMUX_RELAY_JWT_PRIVATE_KEY_PEM: - `-----BEGIN PRIVATE KEY-----\n${"B".repeat(64)}\n-----END PRIVATE KEY-----`, CMUX_RELAY_POLICY_KEY_ID: "relay-policy-current", CMUX_RELAY_POLICY_PRIVATE_KEY_PEM: `-----BEGIN PRIVATE KEY-----\n${"C".repeat(64)}\n-----END PRIVATE KEY-----`, @@ -174,25 +170,6 @@ describe("client config env validation", () => { expect(result.exitCode).toBe(0); }); - test("accepts the self-hosted relay path without the legacy hosted minter", () => { - const result = importEnv({ - ...requiredEnv, - VERCEL: "1", - VERCEL_ENV: "production", - CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", - CMUX_ANALYTICS_RATE_LIMIT_ID: "analytics-rule", - CMUX_IROH_LAN_DISCOVERY_SECRET_B64: requiredIrohProductionEnv.CMUX_IROH_LAN_DISCOVERY_SECRET_B64, - CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: requiredIrohProductionEnv.CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64, - CMUX_IROH_GRANT_SIGNING_KEY_P8: requiredIrohProductionEnv.CMUX_IROH_GRANT_SIGNING_KEY_P8, - CMUX_IROH_GRANT_SIGNING_KID: requiredIrohProductionEnv.CMUX_IROH_GRANT_SIGNING_KID, - CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: - requiredIrohProductionEnv.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, - ...requiredSubrouterDeploymentEnv, - ...requiredRelayProductionEnv, - }); - - expect(result.exitCode).toBe(0); - }); test("allows explicit Vercel production without the optional Iroh limiter id", () => { const result = importEnv({ @@ -223,7 +200,6 @@ describe("client config env validation", () => { expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("CMUX_IROH_GRANT_SIGNING_KEY_P8 is required"); - expect(result.stderr).not.toContain("CMUX_IROH_MINT_HMAC_SECRET_B64 is required"); }); test("requires the self-hosted relay signing and rate-limit configuration in production", () => { @@ -254,59 +230,6 @@ describe("client config env validation", () => { expect(result.exitCode).toBe(0); }); - - test("allows an explicitly opted-in loopback HTTP relay minter only in local development", () => { - const result = inspectIrohMinterUrl({ - ...requiredEnv, - NODE_ENV: "development", - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", - CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("http://localhost:49152/api/relay-token"); - }); - - test("rejects a plaintext non-loopback relay minter in local development", () => { - const result = inspectIrohMinterUrl({ - ...requiredEnv, - NODE_ENV: "development", - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", - CMUX_IROH_MINT_URL: "http://192.168.1.10:49152/api/relay-token", - }); - - expect(result.exitCode).not.toBe(0); - }); - - test("rejects the insecure loopback opt-in in Vercel preview and production", () => { - const preview = inspectIrohMinterUrl({ - ...requiredEnv, - NODE_ENV: "production", - VERCEL: "1", - VERCEL_ENV: "preview", - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", - CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", - }); - expect(preview.exitCode).not.toBe(0); - - const production = inspectIrohMinterUrl({ - ...requiredEnv, - ...requiredIrohProductionEnv, - NODE_ENV: "production", - VERCEL: "1", - VERCEL_ENV: "production", - CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", - CMUX_ANALYTICS_RATE_LIMIT_ID: "analytics-rule", - ...requiredSubrouterDeploymentEnv, - ...requiredRelayProductionEnv, - CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", - CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", - }); - expect(production.exitCode).not.toBe(0); - expect(production.stderr).toContain( - "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER is only allowed in local development", - ); - }); }); function importEnv(env: Record): { exitCode: number; stderr: string } { @@ -323,35 +246,3 @@ function importEnv(env: Record): { exitCode: number; stderr: str stderr: result.stderr, }; } - -function inspectIrohMinterUrl( - env: Record, -): { exitCode: number; stdout: string; stderr: string } { - const result = spawnSync( - process.execPath, - [ - "--no-env-file", - "-e", - ` - const { irohTrustBrokerConfigFromEnv } = await import('./services/iroh/config'); - const { parseMinterUrl } = await import('./services/iroh/relayMinter'); - const config = irohTrustBrokerConfigFromEnv(); - const url = parseMinterUrl(config.relayMinterUrl, { - allowInsecureLoopback: config.relayMinterInsecureLoopbackOptIn, - deploymentEnvironment: config.deploymentEnvironment, - isVercelDeployment: config.isVercelDeployment, - }); - console.log(url.href); - `, - ], - { - env: env as NodeJS.ProcessEnv, - encoding: "utf8", - }, - ); - return { - exitCode: result.status ?? 1, - stdout: result.stdout.trim(), - stderr: result.stderr, - }; -} diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 3dc55195279b..0d7d721d58e8 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -284,13 +284,6 @@ describe("Iroh trust broker database behavior", () => { }); const ios = await pairPeer(iosId); const mac = await pairPeer(macId); - const [issuance] = await requiredSql()>` - insert into iroh_relay_token_issuances ( - user_id, binding_id, endpoint_id_hash, status, requested_at - ) values (${userId}, ${macId}, ${"0f".repeat(32)}, 'pending', ${NOW}) - returning id::text - `; - if (!issuance) throw new Error("issuance insert failed"); await requiredSql()` insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', now()) @@ -322,22 +315,6 @@ describe("Iroh trust broker database behavior", () => { notBefore: NOW, expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), }), - repository.reserveRelayIssuance({ userId, bindingId: macId, now: NOW }), - repository.completeRelayIssuance({ - userId, - issuanceId: issuance.id, - bindingId: macId, - endpointId: mac.endpointId, - tokenHash: "10".repeat(32), - completedAt: NOW, - expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1_000), - }), - repository.failRelayIssuance({ - userId, - issuanceId: issuance.id, - completedAt: NOW, - failureCode: "test_failure", - }), ]; for (const operation of operations) { const exit = await Effect.runPromiseExit(operation); @@ -347,19 +324,16 @@ describe("Iroh trust broker database behavior", () => { const [state] = await requiredSql()>` select exists(select 1 from iroh_endpoint_bindings where id = ${macId} and revoked_at is not null) as revoked, (select count(*)::text from iroh_pair_grant_issuances where user_id = ${userId}) as grants, - (select status from iroh_relay_token_issuances where id = ${issuance.id}) as "issuanceStatus", (select count(*)::text from iroh_account_security_states where user_id = ${userId}) as "securityStates" `; expect(state).toEqual({ revoked: false, grants: "0", - issuanceStatus: "pending", securityStates: "0", }); }); @@ -1878,112 +1852,6 @@ describe("Iroh trust broker database behavior", () => { expect(String(exit)).toContain("IrohNotFoundError"); }); - dbTest("expires abandoned relay reservations before enforcing endpoint and account quotas", async () => { - const repo = requiredRepository(); - const endpointUserId = "user-relay-abandoned-endpoint"; - const endpointBindingId = await insertBinding({ - userId: endpointUserId, - endpointId: "63".repeat(32), - }); - for (let index = 0; index < 3; index += 1) { - await requiredSql()` - insert into iroh_relay_token_issuances ( - user_id, binding_id, endpoint_id_hash, status, requested_at - ) values ( - ${endpointUserId}, ${endpointBindingId}, ${"64".repeat(32)}, 'pending', - ${new Date(NOW.getTime() - 5 * 60 * 1_000 - index * 1_000)} - ) - `; - } - - await Effect.runPromise(repo.reserveRelayIssuance({ - userId: endpointUserId, - bindingId: endpointBindingId, - now: NOW, - })); - const endpointStatuses = await requiredSql()>` - select status, count(*)::text as total - from iroh_relay_token_issuances - where user_id = ${endpointUserId} - group by status - order by status - `; - expect(endpointStatuses).toEqual([ - { status: "expired", total: "3" }, - { status: "pending", total: "1" }, - ]); - - const accountUserId = "user-relay-abandoned-account"; - const accountBindingIds: string[] = []; - for (let index = 0; index < 10; index += 1) { - const bindingId = await insertBinding({ - userId: accountUserId, - endpointId: (0xa0 + index).toString(16).repeat(32), - }); - accountBindingIds.push(bindingId); - await requiredSql()` - insert into iroh_relay_token_issuances ( - user_id, binding_id, endpoint_id_hash, status, requested_at - ) - select - ${accountUserId}, ${bindingId}, ${"65".repeat(32)}, 'pending', - ${new Date(NOW.getTime() - 15 * 60 * 1_000)} - make_interval(secs => value) - from generate_series(1, 10) as values(value) - `; - } - - await Effect.runPromise(repo.reserveRelayIssuance({ - userId: accountUserId, - bindingId: accountBindingIds[0]!, - now: NOW, - })); - const accountStatuses = await requiredSql()>` - select status, count(*)::text as total - from iroh_relay_token_issuances - where user_id = ${accountUserId} - group by status - order by status - `; - expect(accountStatuses).toEqual([ - { status: "expired", total: "100" }, - { status: "pending", total: "1" }, - ]); - }); - - dbTest("fails relay finalization when revocation commits during provider mint", async () => { - const repo = requiredRepository(); - const endpointId = "61".repeat(32); - const bindingId = await insertBinding({ userId: "user-relay-race", endpointId }); - const reservation = await Effect.runPromise(repo.reserveRelayIssuance({ - userId: "user-relay-race", - bindingId, - now: NOW, - })); - expect(await Effect.runPromise(repo.revokeBinding({ - userId: "user-relay-race", - bindingId, - now: new Date(NOW.getTime() + 1_000), - }))).toEqual({ revoked: true, accountRevision: 1 }); - expect(await Effect.runPromise(repo.completeRelayIssuance({ - userId: "user-relay-race", - issuanceId: reservation.issuanceId, - bindingId, - endpointId, - tokenHash: "62".repeat(32), - completedAt: new Date(NOW.getTime() + 2_000), - expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1_000), - }))).toBe(false); - const [issuance] = await requiredSql()>` - select status, failure_code as "failureCode" - from iroh_relay_token_issuances - where id = ${reservation.issuanceId} - `; - expect(issuance).toEqual({ - status: "failed", - failureCode: "binding_inactive_after_mint", - }); - }); - dbTest("global retention clears revoked hints and expired private data from Aurora", async () => { const repo = requiredRepository(); const activeId = await insertBinding({ diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index fc1b70d9114b..e5ebae42628d 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -7,7 +7,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { assertCurrentSigningKey, - createOfflinePairSessionRecord, deriveAccountSubject, deriveLanRendezvousKey, parseVerificationKeys, @@ -16,7 +15,6 @@ import { signPairGrant, verifyEndpointAttestation, verifyEndpointRegistrationSignature, - verifyAndConsumeOfflineSameAccountPair, verifyPairGrant, type EndpointAttestationClaims, type PairGrantClaims, @@ -35,13 +33,6 @@ import { MANAGED_RELAY_URLS, parseRegistrationPayload, } from "../services/iroh/model"; -import { - parseMinterHmacSecret, - parseMinterUrl, - readBoundedMinterJson, - IrohRelayMinter, - IrohRelayMinterLive, -} from "../services/iroh/relayMinter"; const NOW = new Date("2026-07-09T20:00:00.000Z"); @@ -482,111 +473,6 @@ describe("Iroh grant verification keys and offline endpoint attestations", () => )).toThrow(); }); - test("requires two fresh endpoint-bound attestations with the same opaque account subject", () => { - const initiatorToken = signEndpointAttestation({ - privateKeyPem: currentPrivate, - kid: "current", - claims: initiator, - }); - const acceptorToken = signEndpointAttestation({ - privateKeyPem: currentPrivate, - kid: "current", - claims: acceptor, - }); - const expected = { - initiator: { ...endpointExpectation(initiator), platform: "ios" as const }, - acceptor: { ...endpointExpectation(acceptor), platform: "mac" as const }, - nowSeconds, - } as const; - const proof = Buffer.alloc(32, 0x61).toString("base64url"); - const session = createOfflinePairSessionRecord({ - sessionId: "70000000-0000-4000-8000-000000000001", - proof, - acceptor: expected.acceptor, - nowSeconds, - expiresAtSeconds: nowSeconds + 300, - }); - const invitation = { version: 1 as const, sessionId: session.sessionId, proof }; - - expect(verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: initiatorToken, - acceptorAttestation: acceptorToken, - publicKeys: parsedKeys.publicKeys, - expected, - session, - invitation, - }).acceptor.endpointId).toBe(acceptor.endpointId); - expect(session.consumedAtSeconds).toBe(nowSeconds); - expect(() => verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: initiatorToken, - acceptorAttestation: acceptorToken, - publicKeys: parsedKeys.publicKeys, - expected, - session, - invitation, - })).toThrow(); - - const missingSession = createOfflinePairSessionRecord({ - sessionId: "70000000-0000-4000-8000-000000000002", - proof, - acceptor: expected.acceptor, - nowSeconds, - expiresAtSeconds: nowSeconds + 300, - }); - expect(() => verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: "", - acceptorAttestation: acceptorToken, - publicKeys: parsedKeys.publicKeys, - expected, - session: missingSession, - invitation: { ...invitation, sessionId: missingSession.sessionId }, - })).toThrow(); - expect(missingSession.consumedAtSeconds).toBeNull(); - - const wrongProofSession = createOfflinePairSessionRecord({ - sessionId: "70000000-0000-4000-8000-000000000004", - proof, - acceptor: expected.acceptor, - nowSeconds, - expiresAtSeconds: nowSeconds + 300, - }); - expect(() => verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: initiatorToken, - acceptorAttestation: acceptorToken, - publicKeys: parsedKeys.publicKeys, - expected, - session: wrongProofSession, - invitation: { - version: 1, - sessionId: wrongProofSession.sessionId, - proof: Buffer.alloc(32, 0x62).toString("base64url"), - }, - })).toThrow(); - expect(wrongProofSession.consumedAtSeconds).toBeNull(); - - const otherAccountToken = signEndpointAttestation({ - privateKeyPem: currentPrivate, - kid: "current", - claims: { ...acceptor, sub: Buffer.alloc(32, 0x52).toString("base64url") }, - }); - const mismatchSession = createOfflinePairSessionRecord({ - sessionId: "70000000-0000-4000-8000-000000000003", - proof, - acceptor: expected.acceptor, - nowSeconds, - expiresAtSeconds: nowSeconds + 300, - }); - expect(() => verifyAndConsumeOfflineSameAccountPair({ - initiatorAttestation: initiatorToken, - acceptorAttestation: otherAccountToken, - publicKeys: parsedKeys.publicKeys, - expected, - session: mismatchSession, - invitation: { ...invitation, sessionId: mismatchSession.sessionId }, - })).toThrow(); - expect(mismatchSession.consumedAtSeconds).toBeNull(); - }); - test("rejects endpoint substitution, expiry, extra identity claims, and noncanonical signatures", () => { const token = signEndpointAttestation({ privateKeyPem: currentPrivate, @@ -637,188 +523,6 @@ describe("Iroh grant verification keys and offline endpoint attestations", () => }); }); -describe("Iroh relay minter response bounds", () => { - test("the production Live layer sends the canonical fetch body and HMAC", async () => { - const secret = Buffer.alloc(32, 0x63); - const originalFetch = globalThis.fetch; - let captured: { url: string; init: RequestInit } | undefined; - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - captured = { url: String(input), init: init ?? {} }; - return new Response(JSON.stringify({ - token: "a".repeat(64), - expiresAt: "2026-07-10T20:00:00.000Z", - }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; - try { - const config: IrohTrustBrokerConfigShape = { - relayMinterUrl: "https://minter.cmux.test/api/relay-token", - relayMinterHmacSecretBase64: secret.toString("base64"), - relayMinterInsecureLoopbackOptIn: false, - deploymentEnvironment: "test", - isVercelDeployment: false, - }; - const layer = IrohRelayMinterLive.pipe( - Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), - ); - const result = await Effect.runPromise( - Effect.gen(function* () { - const minter = yield* IrohRelayMinter; - return yield* minter.mint({ - endpointId: "ab".repeat(32), - lifetimeSeconds: 86_400, - now: NOW, - }); - }).pipe(Effect.provide(layer)), - ); - expect(result.token).toBe("a".repeat(64)); - } finally { - globalThis.fetch = originalFetch; - } - - const request = captured; - expect(request?.url).toBe("https://minter.cmux.test/api/relay-token"); - expect(request?.init.method).toBe("POST"); - expect(request?.init.redirect).toBe("error"); - const body = JSON.stringify({ endpointId: "ab".repeat(32), lifetimeSeconds: 86_400 }); - expect(request?.init.body).toBe(body); - const timestamp = String(Math.floor(NOW.getTime() / 1_000)); - const expectedSignature = createHmac("sha256", secret) - .update(`POST\n/api/relay-token\n${timestamp}\n${createHash("sha256").update(body).digest("hex")}`) - .digest("base64url"); - expect(new Headers(request?.init.headers).get("x-cmux-iroh-timestamp")).toBe(timestamp); - expect(new Headers(request?.init.headers).get("x-cmux-iroh-signature")).toBe(expectedSignature); - expect(new Headers(request?.init.headers).get("content-type")).toBe("application/json"); - }); - - test("preserves an invalid EndpointID as an input error", async () => { - const config: IrohTrustBrokerConfigShape = { - relayMinterUrl: "https://minter.cmux.test/api/relay-token", - relayMinterHmacSecretBase64: Buffer.alloc(32, 0x63).toString("base64"), - relayMinterInsecureLoopbackOptIn: false, - deploymentEnvironment: "test", - isVercelDeployment: false, - }; - const layer = IrohRelayMinterLive.pipe( - Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), - ); - const exit = await Effect.runPromiseExit( - Effect.gen(function* () { - const minter = yield* IrohRelayMinter; - return yield* minter.mint({ - endpointId: "not-an-endpoint-id", - lifetimeSeconds: 86_400, - now: NOW, - }); - }).pipe(Effect.provide(layer)), - ); - - expect(exit._tag).toBe("Failure"); - const failure = exit._tag === "Failure" - ? Option.getOrUndefined(Cause.failureOption(exit.cause)) - : undefined; - expect((failure as { _tag?: string } | undefined)?._tag).toBe("IrohInvalidInputError"); - }); - - test("matches the Rust minter HMAC wire fixture", () => { - const fixture = JSON.parse(readFileSync( - new URL("../../tests/fixtures/iroh/relay-minter-request-v1.json", import.meta.url), - "utf8", - )) as { path: string; timestamp: string; body: string; signature: string }; - const bodyHash = createHash("sha256").update(fixture.body).digest("hex"); - const signature = createHmac("sha256", Buffer.alloc(32, 0x42)) - .update(`POST\n${fixture.path}\n${fixture.timestamp}\n${bodyHash}`, "utf8") - .digest("base64url"); - expect(fixture.path).toBe("/api/relay-token"); - expect(signature).toBe(fixture.signature); - }); - - test("requires a canonical 32-byte-or-longer HMAC secret", () => { - const valid = Buffer.alloc(32, 9).toString("base64"); - expect(parseMinterHmacSecret(valid)).toEqual(Buffer.alloc(32, 9)); - expect(() => parseMinterHmacSecret("%%%%" + valid)).toThrow(); - expect(() => parseMinterHmacSecret(Buffer.alloc(16, 9).toString("base64"))).toThrow(); - expect(() => parseMinterHmacSecret(Buffer.alloc(257, 9).toString("base64"))).toThrow(); - expect(() => parseMinterHmacSecret(Buffer.alloc(32, 0xff).toString("base64url"))).toThrow(); - }); - - test("allows plaintext only for opted-in local loopback minters", () => { - const localDevelopment = { - allowInsecureLoopback: true, - deploymentEnvironment: "development", - isVercelDeployment: false, - }; - expect(parseMinterUrl("https://minter.cmux.test/api/relay-token").pathname).toBe("/api/relay-token"); - for (const value of [ - "http://localhost:49152/api/relay-token", - "http://127.0.0.1:49152/api/relay-token", - "http://[::1]:49152/api/relay-token", - ]) { - expect(parseMinterUrl(value, localDevelopment).protocol).toBe("http:"); - } - for (const value of [ - "http://minter.cmux.test/api/relay-token", - "http://192.168.1.10:49152/api/relay-token", - "https://minter.cmux.test/api/relay-token/", - "https://minter.cmux.test/other", - "https://minter.cmux.test/api/relay-token?debug=1", - ]) { - expect(() => parseMinterUrl(value, localDevelopment)).toThrow(); - } - expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { - ...localDevelopment, - allowInsecureLoopback: false, - })).toThrow(); - expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { - ...localDevelopment, - deploymentEnvironment: "production", - })).toThrow(); - expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { - ...localDevelopment, - deploymentEnvironment: "preview", - isVercelDeployment: true, - })).toThrow(); - }); - - test("parses a bounded response", async () => { - const body = { token: "a".repeat(32), expiresAt: "2026-07-10T20:00:00.000Z" }; - expect(await readBoundedMinterJson(new Response(JSON.stringify(body), { - headers: { "content-type": "application/json" }, - }))).toEqual(body); - }); - - test("rejects a non-JSON or expanded minter response contract", async () => { - await expect(readBoundedMinterJson(new Response("{}"))).rejects.toThrow(); - await expect(readBoundedMinterJson(new Response(JSON.stringify({ - token: "a".repeat(32), - expiresAt: "2026-07-10T20:00:00.000Z", - servicesSecret: "must-not-appear", - }), { - headers: { "content-type": "application/json" }, - }))).rejects.toThrow(); - }); - - test("rejects oversized fixed-length and chunked responses", async () => { - await expect(readBoundedMinterJson(new Response("{}", { - headers: { "content-length": "999999", "content-type": "application/json" }, - }))).rejects.toThrow(); - - const chunk = new Uint8Array(20_000); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(chunk); - controller.enqueue(chunk); - controller.close(); - }, - }); - await expect(readBoundedMinterJson(new Response(stream, { - headers: { "content-type": "application/json" }, - }))).rejects.toThrow(); - }); -}); - function manuallySignedJws(header: unknown, claims: unknown, privateKey: CryptoKey | import("node:crypto").KeyObject): string { const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url"); const encodedClaims = Buffer.from(JSON.stringify(claims)).toString("base64url"); diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index 4bb17b0a48ca..568f26d2b23d 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; -import { IrohDatabaseError, IrohQuotaExceededError } from "../services/iroh/errors"; +import { IrohDatabaseError } from "../services/iroh/errors"; import { buildConnectivityInvalidationRequest, handleIrohRoute, @@ -328,13 +328,11 @@ describe("Iroh route boundary", () => { issueEndpointAttestation: namespaced, revoke: namespaced, issuePairGrant: namespaced, - issueRelayToken: namespaced, }); const operations = [ "endpoint_attestation", "revoke", "pair_grant", - "relay_token", ] as const; for (const operation of operations) { const base = authedPost("/api/devices/iroh", {}); @@ -353,32 +351,12 @@ describe("Iroh route boundary", () => { ); expect(response.status).toBe(operation === "revoke" ? 200 : 201); } - expect(received).toEqual(Array(4).fill("dev.cmux.app.demo")); + expect(received).toEqual(Array(3).fill("dev.cmux.app.demo")); expect(receivedBindingIDs).toEqual( - Array(4).fill("123e4567-e89b-42d3-a456-426614174000"), + Array(3).fill("123e4567-e89b-42d3-a456-426614174000"), ); }); - test("maps DB-authoritative quota failures to typed 429 with Retry-After", async () => { - const response = await handleIrohRoute(authedPost("/api/devices/iroh/relay-token", { - bindingId: "30000000-0000-4000-8000-000000000001", - }), "relay_token", { - verify: async () => USER, - broker: broker({ - issueRelayToken: () => Effect.fail(new IrohQuotaExceededError({ - code: "relay_endpoint_10m_quota", - retryAfterSeconds: 417, - })), - }), - }); - expect(response.status).toBe(429); - expect(response.headers.get("retry-after")).toBe("417"); - expect(await response.json()).toEqual({ - error: "relay_endpoint_10m_quota", - retry_after_seconds: 417, - }); - }); - test("does not expose database implementation details in service failures", async () => { const response = await handleIrohRoute(authedPost("/api/devices/iroh/challenge", {}), "challenge", { verify: async () => USER, @@ -435,7 +413,6 @@ function broker(overrides: Partial = {}): IrohTrustBrokerS issueEndpointAttestation: unavailable, revoke: unavailable, issuePairGrant: unavailable, - issueRelayToken: unavailable, ...overrides, }; } diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 8938329fbcb2..3fd9e588462d 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -15,10 +15,8 @@ import { IrohConflictError, IrohForbiddenError, IrohNotFoundError, - IrohRelayMintError, } from "../services/iroh/errors"; import { - IROH_RELAY_TOKEN_LIFETIME_SECONDS, MANAGED_RELAY_URLS, sha256, type IrohRegistrationPayload, @@ -33,9 +31,8 @@ import { type IrohChallengeRecord, type IrohRepositoryShape, } from "../services/iroh/repository"; -import type { IrohRelayMinterShape } from "../services/iroh/relayMinter"; import { makeIrohTrustBroker } from "../services/iroh/trustBroker"; -import { bindingMatchesDiscoveryScope } from "../services/iroh/discoveryScope"; +import type { IrohDiscoveryScope } from "../services/iroh/discoveryScope"; import type { RelayPreference } from "../services/relay/model"; const NOW = new Date("2026-07-09T20:00:00.000Z"); @@ -206,13 +203,13 @@ describe("Iroh build compatibility", () => { }); describe("Iroh trust broker registration", () => { - test("registers a valid endpoint proof and mints relay credentials after commit", async () => { + test("registers a valid endpoint proof and reports the relay credential unavailable", async () => { const fixture = makeFixture(); const request = await fixture.signedRegistration(); const result = await Effect.runPromise(fixture.broker.register(USER_A, request, NOW)) as { revision: number; binding: { endpoint_id: string }; - relay: { status: string; token: string }; + relay: { status: string }; discovery_complete: boolean; discovery: { revision: number; @@ -220,7 +217,7 @@ describe("Iroh trust broker registration", () => { }; }; expect(result.binding.endpoint_id).toBe(fixture.endpointId); - expect(result.relay.status).toBe("issued"); + expect(result.relay.status).toBe("unavailable"); expect(result.discovery.revision).toBe(result.revision); expect(result.discovery_complete).toBe(true); expect(result.discovery.bindings.map((binding) => binding.binding_id)) @@ -234,7 +231,6 @@ describe("Iroh trust broker registration", () => { observed_at: "2026-07-09T19:55:00.000Z", expires_at: "2026-07-09T20:45:00.000Z", }]); - expect(fixture.minter.calls).toBe(1); }); test("persists and publishes signed family-specific direct ports to the same account", async () => { @@ -345,16 +341,7 @@ describe("Iroh trust broker registration", () => { ]); }); - test("relay failure cannot roll back an authenticated registration", async () => { - const fixture = makeFixture({ minterFailure: true }); - const result = await Effect.runPromise( - fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW), - ) as { relay: { status: string } }; - expect(result.relay.status).toBe("unavailable"); - expect(fixture.repository.bindings).toHaveLength(1); - }); - - test("does not mint another relay token when refreshing the same binding", async () => { + test("reports not_requested when refreshing the same binding", async () => { const fixture = makeFixture(); await Effect.runPromise(fixture.broker.register( USER_A, @@ -369,7 +356,6 @@ describe("Iroh trust broker registration", () => { )) as { relay: { status: string } }; expect(refreshed.relay.status).toBe("not_requested"); - expect(fixture.minter.calls).toBe(1); }); test("marks a truncated registration discovery page incomplete", async () => { @@ -1337,6 +1323,87 @@ describe("Iroh discovery and grants", () => { expect(discovered.bindings[0]?.path_hints).toEqual([]); }); + test("serves the fleet-reported attach route ahead of client-published hints", async () => { + const attachedURL = MANAGED_RELAY_URLS[0]!; + const otherManagedURL = MANAGED_RELAY_URLS[1]!; + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + // The client republish also advertised the same relay plus another one. + pathHints: [relayHint(attachedURL), relayHint(otherManagedURL)], + relayAttachedUrl: attachedURL, + relayAttachReportedAt: new Date(NOW.getTime() - 60_000), + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: Array> }>; + }; + const hints = discovered.bindings[0]!.path_hints; + // Server-observed hint first, the duplicate client hint dropped, the + // remaining client hint kept as fallback. + expect(hints.map((hint) => hint.value)).toEqual([attachedURL, otherManagedURL]); + expect(hints[0]).toMatchObject({ + kind: "relay_url", + source: "native", + privacy_scope: "public_internet", + observed_at: NOW.toISOString(), + }); + expect(new Date(String(hints[0]!.expires_at)).getTime()) + .toBeGreaterThan(NOW.getTime()); + }); + + test("ages out an attach route with no fresh evidence (lost detach report)", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [], + relayAttachedUrl: MANAGED_RELAY_URLS[0]!, + // Both evidence channels are stale: the relay died without a detach + // report and the Mac stopped renewing its registration. + relayAttachReportedAt: new Date(NOW.getTime() - 2 * 60 * 60 * 1_000), + lastSeenAt: new Date(NOW.getTime() - 2 * 60 * 60 * 1_000), + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: unknown[] }>; + }; + expect(discovered.bindings[0]?.path_hints).toEqual([]); + }); + + test("a fresh attach report keeps the route while the binding lease is stale", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [], + relayAttachedUrl: MANAGED_RELAY_URLS[0]!, + relayAttachReportedAt: new Date(NOW.getTime() - 5 * 60 * 1_000), + // Broker unreachable from the Mac (cache-first world) while the relay + // path works: the attach report alone corroborates the route. + lastSeenAt: new Date(NOW.getTime() - 2 * 60 * 60 * 1_000), + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: Array> }>; + }; + expect(discovered.bindings[0]?.path_hints.map((hint) => hint.value)) + .toEqual([MANAGED_RELAY_URLS[0]!]); + }); + + test("withholds a fleet-reported custom relay the account no longer saves", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [], + relayAttachedUrl: "https://relay.example.net/", + relayAttachReportedAt: new Date(NOW.getTime() - 60_000), + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: unknown[] }>; + }; + expect(discovered.bindings[0]?.path_hints).toEqual([]); + }); + test("does not combine a pre-revocation binding with a post-revocation LAN generation", async () => { const fixture = makeFixture(); const active = binding({ userId: USER_A }); @@ -1704,18 +1771,6 @@ describe("Iroh discovery and grants", () => { bindingBody, ), ), "IrohNotFoundError"); - await expectEffectFailure(fixture.broker.issueRelayToken( - USER_A, - bindingBody, - NOW, - "dev.cmux.app.beta", - fixture.bindingProof( - beta.id, - "POST", - "api/relay/token", - bindingBody, - ), - ), "IrohNotFoundError"); const pairBody = { initiatorBindingId: internal.id, acceptorBindingId: mac.id, @@ -1734,7 +1789,6 @@ describe("Iroh discovery and grants", () => { ), "IrohNotFoundError"); expect(internal.revokedAt).toBeNull(); - expect(fixture.minter.calls).toBe(0); expect(fixture.repository.pairGrantAudits).toHaveLength(0); }); @@ -1835,13 +1889,13 @@ describe("Iroh discovery and grants", () => { const fixture = makeFixture(); const active = binding({ userId: USER_A, platform: "ios" }); fixture.repository.bindings.push(active); - const noVerificationKeys = makeIrohTrustBroker(fixture.repository, fixture.minter, { + const noVerificationKeys = makeIrohTrustBroker(fixture.repository, { ...fixture.config, grantVerificationKeysJson: undefined, }); await expectEffectFailure(noVerificationKeys.discover(USER_A, NOW), "IrohConfigurationError"); - const noAccountSubject = makeIrohTrustBroker(fixture.repository, fixture.minter, { + const noAccountSubject = makeIrohTrustBroker(fixture.repository, { ...fixture.config, accountSubjectSecretBase64: undefined, }); @@ -1851,74 +1905,49 @@ describe("Iroh discovery and grants", () => { }); }); -describe("Iroh relay quotas", () => { - test("never calls the minter for an unregistered or revoked binding", async () => { - const fixture = makeFixture(); - await expectEffectFailure( - fixture.broker.issueRelayToken(USER_A, { bindingId: randomUUID() }, NOW), - "IrohNotFoundError", - ); - const revoked = binding({ userId: USER_A, revokedAt: NOW }); - fixture.repository.bindings.push(revoked); - await expectEffectFailure( - fixture.broker.issueRelayToken(USER_A, { bindingId: revoked.id }, NOW), - "IrohNotFoundError", - ); - expect(fixture.minter.calls).toBe(0); - }); - - test("treats authenticated relay renewal as binding activity", async () => { - const fixture = makeFixture(); - const active = binding({ - userId: USER_A, - lastSeenAt: new Date(NOW.getTime() - 48 * 60 * 60 * 1_000), - updatedAt: new Date(NOW.getTime() - 48 * 60 * 60 * 1_000), - }); - fixture.repository.bindings.push(active); - - await Effect.runPromise(fixture.broker.issueRelayToken( - USER_A, - { bindingId: active.id }, - NOW, - )); - - expect(active.lastSeenAt).toEqual(NOW); - expect(active.updatedAt).toEqual(NOW); - }); - - test("does not return a relay credential when the binding is revoked during mint", async () => { - const fixture = makeFixture(); - const active = binding({ userId: USER_A }); - fixture.repository.bindings.push(active); - fixture.minter.afterMint = () => { - active.revokedAt = NOW; - }; - - await expectEffectFailure( - fixture.broker.issueRelayToken(USER_A, { bindingId: active.id }, NOW), - "IrohNotFoundError", - ); - expect(fixture.repository.relayIssuances[0]?.status).toBe("failed"); - }); -}); - type MutableBinding = IrohBindingRecord & { userId: string; directPortV4: number | null; directPortV6: number | null; }; +// Mirrors the live repository's SQL discovery-scope filter for the in-memory +// fixture (the production filter lives in repository.ts SQL, not TypeScript). +function bindingMatchesDiscoveryScope( + binding: { + readonly deviceUuid: string; + readonly appInstanceId: string; + readonly tag: string; + readonly platform: string; + readonly pairingEnabled: boolean; + }, + scope: IrohDiscoveryScope, +): boolean { + const local = scope.localBinding; + if ( + binding.deviceUuid === local.deviceId + && binding.appInstanceId === local.appInstanceId + && binding.tag === local.tag + && binding.platform === local.platform + ) { + return true; + } + const peers = scope.peerBindings; + return binding.platform === peers.platform + && ( + peers.tags === undefined + || peers.tags.includes(binding.tag.toLowerCase()) + ) + && ( + peers.pairingEnabled === undefined + || binding.pairingEnabled === peers.pairingEnabled + ); +} + class MemoryRepository implements IrohRepositoryShape { readonly challenges: IrohChallengeRecord[] = []; readonly bindings: MutableBinding[] = []; readonly pairGrantAudits: unknown[] = []; - readonly relayIssuances: Array<{ - id: string; - userId: string; - bindingId: string; - requestedAt: Date; - status: string; - }> = []; private lanGenerations = new Map(); private routeRevisions = new Map(); beforeDiscoverySnapshot: (() => Promise) | undefined; @@ -2324,63 +2353,10 @@ class MemoryRepository implements IrohRepositoryShape { } return Effect.void; } - - reserveRelayIssuance(input: Parameters[0]) { - const active = this.bindings.find((row) => - row.id === input.bindingId && row.userId === input.userId && !row.revokedAt); - if (!active) return Effect.fail(new IrohNotFoundError({ resource: "binding" })); - if (active.clientNamespace !== (input.clientNamespace ?? "legacy")) { - return Effect.fail(new IrohNotFoundError({ resource: "binding" })); - } - active.lastSeenAt = input.now; - active.updatedAt = input.now; - const issuanceId = randomUUID(); - this.relayIssuances.push({ id: issuanceId, userId: input.userId, bindingId: active.id, requestedAt: input.now, status: "pending" }); - return Effect.succeed({ issuanceId, binding: active }); - } - - completeRelayIssuance(input: Parameters[0]) { - const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); - const active = this.bindings.find((candidate) => - candidate.id === input.bindingId && - candidate.userId === input.userId && - candidate.endpointId === input.endpointId && - !candidate.revokedAt); - if (!row || !active) { - if (row) row.status = "failed"; - return Effect.succeed(false); - } - row.status = "succeeded"; - return Effect.succeed(true); - } - - failRelayIssuance(input: Parameters[0]) { - const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); - if (row) row.status = "failed"; - return Effect.void; - } -} - -class FakeMinter implements IrohRelayMinterShape { - calls = 0; - afterMint: (() => void) | undefined; - constructor(private readonly fail: boolean) {} - - mint(input: Parameters[0]) { - this.calls += 1; - if (this.fail) return Effect.fail(new IrohRelayMintError({ code: "test_failure" })); - const result = { - token: `relay-token-${this.calls}-with-safe-length`, - expiresAt: new Date(input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000), - }; - this.afterMint?.(); - return Effect.succeed(result); - } } function makeFixture(options: { repository?: MemoryRepository; - minterFailure?: boolean; appInstanceId?: string; deviceId?: string; identityGeneration?: number; @@ -2399,7 +2375,6 @@ function makeFixture(options: { const endpointPublicDer = endpointKeys.publicKey.export({ format: "der", type: "spki" }); const endpointId = Buffer.from(endpointPublicDer).subarray(-32).toString("hex"); const repository = options.repository ?? new MemoryRepository(); - const minter = new FakeMinter(options.minterFailure ?? false); const appInstanceId = options.appInstanceId ?? randomUUID(); const deviceId = options.deviceId ?? randomUUID(); const identityGeneration = options.identityGeneration ?? 1; @@ -2424,22 +2399,18 @@ function makeFixture(options: { }, ], }), - relayMinterInsecureLoopbackOptIn: false, - deploymentEnvironment: "test", - isVercelDeployment: false, }; let relayPreference = options.relayPreference ?? { mode: "automatic" as const, selectedManagedRelayIds: [], customRelays: [], }; - const broker = makeIrohTrustBroker(repository, minter, config, { + const broker = makeIrohTrustBroker(repository, config, { getPreference: () => Effect.succeed({ preference: relayPreference, revision: 0 }), }); return { repository, - minter, broker, config, endpointId, @@ -2556,6 +2527,8 @@ function binding(overrides: Partial = {}): MutableBinding { directPortV6: null, pathHints: [], pathHintsNextExpiry: null, + relayAttachedUrl: null, + relayAttachReportedAt: null, deviceLimitOverrideUsed: false, lastSeenAt: now, registeredAt: now, diff --git a/web/tests/relay-report-db-behavior.test.ts b/web/tests/relay-report-db-behavior.test.ts new file mode 100644 index 000000000000..8bb67505b356 --- /dev/null +++ b/web/tests/relay-report-db-behavior.test.ts @@ -0,0 +1,335 @@ +// Database-backed tests of the relay attach-report registry behavior behind +// POST /api/relay/report: the route tests fake the application, so the +// ordering guards, the custom-relay trust join, revocation, and the +// discovery read that serves the attach-derived hint to a phone are proven +// here against Postgres. Gated like tests/iroh-db-behavior.test.ts. + +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; +import * as Effect from "effect/Effect"; +import postgres, { type Sql } from "postgres"; + +import { closeCloudDbForTests } from "../db/client"; +import type { IrohTrustBrokerConfigShape } from "../services/iroh/config"; +import type { IrohPathHint } from "../services/iroh/model"; +import { + IrohRepository, + IrohRepositoryLive, + type IrohRepositoryShape, +} from "../services/iroh/repository"; +import { makeIrohTrustBroker } from "../services/iroh/trustBroker"; +import { + applyRelayAttachReport, + closeRelayReportClientForTests, + type RelayAttachReport, +} from "../services/relay/report"; + +const runDbTests = process.env.CMUX_DB_TEST === "1"; +const dbTest = runDbTests ? test : test.skip; + +const USER_ID = "user-report"; +const ENDPOINT_ID = "ab".repeat(32); +const MANAGED_HOSTNAME = "usc1.relay.cmux.dev"; +const MANAGED_URL = "https://usc1.relay.cmux.dev/"; +const OTHER_MANAGED_HOSTNAME = "euw4.relay.cmux.dev"; +const CUSTOM_HOSTNAME = "relay.corp.example"; +const CUSTOM_URL = "https://relay.corp.example:8443/"; +const T0 = 1_756_100_000_000; + +let sql: Sql | null = null; +let repository: IrohRepositoryShape | null = null; + +function requiredSql(): Sql { + if (!sql) throw new Error("sql not initialized"); + return sql; +} + +beforeAll(async () => { + if (!runDbTests) return; + const databaseURL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL; + if (!databaseURL) throw new Error("DATABASE_URL is required when CMUX_DB_TEST=1"); + sql = postgres(databaseURL, { max: 4 }); + repository = await Effect.runPromise( + Effect.gen(function* () { return yield* IrohRepository; }).pipe( + Effect.provide(IrohRepositoryLive), + ), + ); +}); + +beforeEach(async () => { + if (!sql) return; + await sql` + truncate + iroh_relay_token_issuances, + iroh_pair_grant_issuances, + iroh_registration_challenges, + iroh_endpoint_bindings, + iroh_relay_preferences, + account_deletion_tombstones + restart identity cascade + `; +}); + +afterAll(async () => { + await closeRelayReportClientForTests(); + await closeCloudDbForTests(); + await sql?.end(); +}); + +async function insertBinding(input: { + readonly userId?: string; + readonly endpointId?: string; + readonly revokedAt?: Date; +} = {}): Promise { + await requiredSql()` + insert into iroh_endpoint_bindings ( + user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, + identity_generation, pairing_enabled, revoked_at, revoked_reason + ) values ( + ${input.userId ?? USER_ID}, ${randomUUID()}, ${randomUUID()}, 'stable', + 'mac', ${input.endpointId ?? ENDPOINT_ID}, 1, true, + ${input.revokedAt ?? null}, + ${input.revokedAt ? "user_requested" : null} + ) + `; +} + +async function saveCustomRelay(userId: string, url: string): Promise { + const sql = requiredSql(); + await sql` + insert into iroh_relay_preferences (account_id, mode, selected_managed_relay_ids, custom_relays) + values ( + ${userId}, 'custom', '[]'::jsonb, + ${sql.json([{ + id: "corp1", + provider: "corp", + region: "on-prem", + url, + authMode: "none", + }])} + ) + `; +} + +function report(overrides: Partial = {}): RelayAttachReport { + return { + endpointId: ENDPOINT_ID, + event: "attach", + relayId: MANAGED_HOSTNAME, + reportedAt: new Date(T0), + ...overrides, + }; +} + +async function attachState(): Promise<{ url: string | null; reportedAt: Date | null }> { + const [row] = await requiredSql()>` + select relay_attached_url as url, relay_attach_reported_at as "reportedAt" + from iroh_endpoint_bindings + where endpoint_id = ${ENDPOINT_ID} + `; + if (!row) throw new Error("binding row missing"); + return row; +} + +describe("relay attach report registry behavior", () => { + dbTest("publishes a managed relay attachment for an active binding", async () => { + await insertBinding(); + expect(await applyRelayAttachReport(report())).toBe("applied"); + expect(await attachState()).toEqual({ + url: MANAGED_URL, + reportedAt: new Date(T0), + }); + }); + + dbTest("a matching detach clears the published route", async () => { + await insertBinding(); + await applyRelayAttachReport(report()); + expect(await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0 + 1_000), + }))).toBe("applied"); + expect(await attachState()).toEqual({ + url: null, + reportedAt: new Date(T0 + 1_000), + }); + }); + + dbTest("drops an out-of-order older attach after a newer detach", async () => { + await insertBinding(); + await applyRelayAttachReport(report({ reportedAt: new Date(T0) })); + await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0 + 2_000), + })); + expect(await applyRelayAttachReport(report({ + reportedAt: new Date(T0 + 1_000), + }))).toBe("superseded"); + expect((await attachState()).url).toBeNull(); + }); + + dbTest("an attach that ties a detach timestamp wins (make-before-break)", async () => { + await insertBinding(); + await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0), + })); + expect(await applyRelayAttachReport(report({ + reportedAt: new Date(T0), + }))).toBe("applied"); + expect((await attachState()).url).toBe(MANAGED_URL); + }); + + dbTest("a late detach from an old relay cannot clear a newer attachment", async () => { + await insertBinding(); + await applyRelayAttachReport(report({ + relayId: OTHER_MANAGED_HOSTNAME, + reportedAt: new Date(T0), + })); + await applyRelayAttachReport(report({ reportedAt: new Date(T0 + 5_000) })); + expect(await applyRelayAttachReport(report({ + event: "detach", + relayId: OTHER_MANAGED_HOSTNAME, + reportedAt: new Date(T0 + 6_000), + }))).toBe("superseded"); + expect(await attachState()).toEqual({ + url: MANAGED_URL, + reportedAt: new Date(T0 + 5_000), + }); + }); + + dbTest("ignores reports about unknown endpoints", async () => { + expect(await applyRelayAttachReport(report())).toBe("unknown_endpoint"); + }); + + dbTest("ignores reports about revoked bindings", async () => { + await insertBinding({ revokedAt: new Date(T0) }); + expect(await applyRelayAttachReport(report())).toBe("unknown_endpoint"); + }); + + dbTest("refuses a relay outside the catalog and the account's saved set", async () => { + await insertBinding(); + expect(await applyRelayAttachReport(report({ + relayId: CUSTOM_HOSTNAME, + }))).toBe("untrusted_relay"); + expect((await attachState()).url).toBeNull(); + }); + + dbTest("publishes the saved custom relay URL verbatim for its hostname", async () => { + await insertBinding(); + await saveCustomRelay(USER_ID, CUSTOM_URL); + expect(await applyRelayAttachReport(report({ + relayId: CUSTOM_HOSTNAME, + }))).toBe("applied"); + expect((await attachState()).url).toBe(CUSTOM_URL); + }); + + dbTest("a custom relay deleted from preferences can still detach cleanly", async () => { + await insertBinding(); + await saveCustomRelay(USER_ID, CUSTOM_URL); + await applyRelayAttachReport(report({ relayId: CUSTOM_HOSTNAME })); + await requiredSql()`delete from iroh_relay_preferences where account_id = ${USER_ID}`; + expect(await applyRelayAttachReport(report({ + event: "detach", + relayId: CUSTOM_HOSTNAME, + reportedAt: new Date(T0 + 1_000), + }))).toBe("applied"); + expect((await attachState()).url).toBeNull(); + }); + + dbTest("a detach for a hostname nothing is attached to is superseded", async () => { + await insertBinding(); + expect(await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0), + }))).toBe("superseded"); + }); + + dbTest("another account's saved custom relay grants no trust", async () => { + await insertBinding(); + await saveCustomRelay("user-other", CUSTOM_URL); + expect(await applyRelayAttachReport(report({ + relayId: CUSTOM_HOSTNAME, + }))).toBe("untrusted_relay"); + }); + + dbTest("revocation clears published attach state", async () => { + await insertBinding(); + await applyRelayAttachReport(report()); + const [binding] = await requiredSql()>` + select id from iroh_endpoint_bindings where endpoint_id = ${ENDPOINT_ID} + `; + if (!repository || !binding) throw new Error("repository not initialized"); + await Effect.runPromise(repository.revokeBinding({ + userId: USER_ID, + bindingId: binding.id, + now: new Date(T0 + 1_000), + })); + const [row] = await requiredSql()>` + select relay_attached_url as url from iroh_endpoint_bindings + where id = ${binding.id} + `; + expect(row?.url ?? null).toBeNull(); + }); +}); + +describe("phone discovery serves the attach-derived relay route", () => { + dbTest("a discover after a simulated attach report carries the relay hint", async () => { + if (!repository) throw new Error("repository not initialized"); + await insertBinding(); + await applyRelayAttachReport(report()); + + const broker = makeIrohTrustBroker(repository, brokerConfig()); + const discovery = await Effect.runPromise( + broker.discover(USER_ID, new Date(T0 + 10_000)), + ) as { + bindings: ReadonlyArray<{ endpoint_id: string; path_hints: IrohPathHint[] }>; + }; + + const mac = discovery.bindings.find((entry) => entry.endpoint_id === ENDPOINT_ID); + expect(mac).toBeDefined(); + const relayHints = (mac?.path_hints ?? []).filter((hint) => hint.kind === "relay_url"); + expect(relayHints.map((hint) => hint.value)).toEqual([MANAGED_URL]); + // The synthesized hint is dialable under the standard client rules. + expect(relayHints[0]?.source).toBe("native"); + expect(relayHints[0]?.privacy_scope).toBe("public_internet"); + expect(new Date(relayHints[0]?.expires_at ?? 0).getTime()) + .toBeGreaterThan(T0 + 10_000); + + // After a detach report the same fetch no longer advertises the relay. + await applyRelayAttachReport(report({ + event: "detach", + reportedAt: new Date(T0 + 20_000), + })); + const after = await Effect.runPromise( + broker.discover(USER_ID, new Date(T0 + 30_000)), + ) as { + bindings: ReadonlyArray<{ endpoint_id: string; path_hints: IrohPathHint[] }>; + }; + const macAfter = after.bindings.find((entry) => entry.endpoint_id === ENDPOINT_ID); + expect((macAfter?.path_hints ?? []).filter((hint) => hint.kind === "relay_url")) + .toEqual([]); + }); +}); + +function brokerConfig(): IrohTrustBrokerConfigShape { + const grantKeys = generateKeyPairSync("ed25519"); + return { + lanDiscoverySecretBase64: Buffer.alloc(32, 7).toString("base64"), + accountSubjectSecretBase64: Buffer.alloc(32, 8).toString("base64"), + grantSigningPrivateKeyPem: grantKeys.privateKey + .export({ format: "pem", type: "pkcs8" }) + .toString(), + grantSigningKid: "current", + grantVerificationKeysJson: JSON.stringify({ + version: 1, + current_kid: "current", + keys: [{ + kid: "current", + alg: "EdDSA", + spki_der_base64: grantKeys.publicKey + .export({ format: "der", type: "spki" }) + .toString("base64"), + }], + }), + }; +} diff --git a/web/tests/relay-report-route.test.ts b/web/tests/relay-report-route.test.ts new file mode 100644 index 000000000000..3b011107cbbc --- /dev/null +++ b/web/tests/relay-report-route.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; + +import { + handleRelayReportRequest, + type RelayReportDeps, +} from "../app/api/relay/report/route"; +import { relayAllowSignature } from "../services/relay/allow"; +import { + RELAY_REPORT_MAX_CONCURRENT, + RELAY_REPORT_MAX_FUTURE_SKEW_MS, + RELAY_REPORT_MAX_PAST_SKEW_MS, + RELAY_REPORT_SIGNATURE_HEADER, + RelayReportSaturatedError, + parseRelayAttachReport, + publishableRelayURLForHostname, + withRelayReportSlot, + type RelayAttachReport, +} from "../services/relay/report"; + +// Pure route tests: deps injection only, nothing leaks into the shared +// bun-test module registry, no database. +const SECRET = randomBytes(32); +const SECRET_B64 = SECRET.toString("base64"); +const ENDPOINT_ID = "0123456789abcdef".repeat(4); +const NOW = new Date("2026-08-25T12:00:00.000Z"); +const MANAGED_HOSTNAME = "usc1.relay.cmux.dev"; +const MANAGED_URL = "https://usc1.relay.cmux.dev/"; + +function deps(overrides: Partial = {}): RelayReportDeps { + return { + secretBase64: () => SECRET_B64, + apply: async () => "applied", + now: () => NOW, + ...overrides, + }; +} + +function reportBody(overrides: Record = {}): Record { + return { + endpointId: ENDPOINT_ID, + event: "attach", + relayId: MANAGED_HOSTNAME, + ts: NOW.getTime(), + ...overrides, + }; +} + +/** The exact shape the cmux-relay Reporter sends: JSON body, signature header. */ +function signedRequest(body: unknown, signature?: string): Request { + const text = JSON.stringify(body); + return new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + "content-type": "application/json", + [RELAY_REPORT_SIGNATURE_HEADER]: + signature ?? relayAllowSignature(SECRET, Buffer.from(text, "utf8")), + }, + body: text, + }); +} + +describe("POST /api/relay/report", () => { + test("applies a signed attach report, uncacheable", async () => { + const observed: RelayAttachReport[] = []; + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ + apply: async (report) => { + observed.push(report); + return "applied"; + }, + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ applied: true }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(observed).toEqual([{ + endpointId: ENDPOINT_ID, + event: "attach", + relayId: MANAGED_HOSTNAME, + reportedAt: NOW, + }]); + }); + + test("applies a signed detach report", async () => { + const observed: RelayAttachReport[] = []; + const response = await handleRelayReportRequest( + signedRequest(reportBody({ event: "detach" })), + deps({ + apply: async (report) => { + observed.push(report); + return "applied"; + }, + }), + ); + expect(response.status).toBe(200); + expect(observed[0]?.event).toBe("detach"); + }); + + test("rejects a missing signature without touching the registry", async () => { + let applications = 0; + const text = JSON.stringify(reportBody()); + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { "content-type": "application/json" }, + body: text, + }), + deps({ + apply: async () => { + applications += 1; + return "applied"; + }, + }), + ); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "invalid_relay_report_signature" }); + expect(applications).toBe(0); + }); + + test("rejects a signature over different body bytes", async () => { + const response = await handleRelayReportRequest( + signedRequest( + reportBody(), + relayAllowSignature(SECRET, Buffer.from("{}", "utf8")), + ), + deps(), + ); + expect(response.status).toBe(401); + }); + + test("rejects a signature minted with the wrong secret", async () => { + const text = JSON.stringify(reportBody()); + const response = await handleRelayReportRequest( + signedRequest( + reportBody(), + relayAllowSignature(randomBytes(32), Buffer.from(text, "utf8")), + ), + deps(), + ); + expect(response.status).toBe(401); + }); + + test("answers 503 when the shared secret is not configured", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ secretBase64: () => undefined }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "relay_report_not_configured" }); + }); + + test("rejects a signed empty body", async () => { + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, new Uint8Array()), + }, + }), + deps(), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "missing_report_body" }); + }); + + test("rejects signed malformed JSON", async () => { + const text = "{not json"; + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, Buffer.from(text, "utf8")), + }, + body: text, + }), + deps(), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_json" }); + }); + + test("rejects malformed reports with the specific failure code", async () => { + const cases: ReadonlyArray = [ + [reportBody({ endpointId: "not-hex" }), "invalid_endpoint_id"], + [reportBody({ endpointId: ENDPOINT_ID.slice(1) }), "invalid_endpoint_id"], + [reportBody({ event: "connected" }), "invalid_report_event"], + [reportBody({ relayId: "" }), "invalid_relay_id"], + [reportBody({ relayId: "bad_host!" }), "invalid_relay_id"], + [reportBody({ relayId: `${"a".repeat(64)}.example` }), "invalid_relay_id"], + [reportBody({ ts: "123" }), "invalid_report_time"], + [reportBody({ ts: 0 }), "invalid_report_time"], + [reportBody({ ts: 1.5 }), "invalid_report_time"], + [reportBody({ extra: true }), "invalid_report_body"], + [[reportBody()], "invalid_report_body"], + ]; + for (const [body, error] of cases) { + const response = await handleRelayReportRequest(signedRequest(body), deps()); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error }); + } + }); + + test("rejects an event timestamp too far in the future", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody({ + ts: NOW.getTime() + RELAY_REPORT_MAX_FUTURE_SKEW_MS + 1, + })), + deps(), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_report_time" }); + }); + + test("rejects a replayed old event timestamp", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody({ + ts: NOW.getTime() - RELAY_REPORT_MAX_PAST_SKEW_MS - 1, + })), + deps(), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_report_time" }); + }); + + test("rejects an oversized declared body without reading it", async () => { + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + "content-length": String(1024 * 1024), + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, new Uint8Array()), + }, + body: new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024)); + }, + }), + }), + deps(), + ); + expect(response.status).toBe(413); + }); + + test("rejects an oversized streamed body at the byte cap", async () => { + const chunk = new Uint8Array(1024); + let sent = 0; + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, new Uint8Array()), + }, + body: new ReadableStream({ + pull(controller) { + sent += 1; + if (sent > 32) { + controller.close(); + return; + } + controller.enqueue(chunk); + }, + }), + }), + deps(), + ); + expect(response.status).toBe(413); + }); + + test("times out a trickled body instead of waiting forever", async () => { + const response = await handleRelayReportRequest( + new Request("https://cmux.dev/api/relay/report", { + method: "POST", + headers: { + [RELAY_REPORT_SIGNATURE_HEADER]: + relayAllowSignature(SECRET, new Uint8Array()), + }, + // A stream that never produces data and never closes. + body: new ReadableStream({ pull: () => new Promise(() => {}) }), + }), + deps({ bodyReadTimeoutMs: 25 }), + ); + expect(response.status).toBe(408); + }); + + test("answers 403 for a trusted-signature report about an untrusted relay", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ apply: async () => "untrusted_relay" }), + ); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "untrusted_relay" }); + }); + + test("answers applied:false for stale or unknown reports without failing the relay", async () => { + for (const outcome of ["superseded", "unknown_endpoint"] as const) { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ apply: async () => outcome }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ applied: false, reason: outcome }); + } + }); + + test("bounds application latency and fails to 503 on expiry", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ + apply: () => new Promise(() => {}), + applyTimeoutMs: 25, + }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "relay_report_unavailable" }); + }); + + test("answers 503 when report concurrency is saturated", async () => { + const response = await handleRelayReportRequest( + signedRequest(reportBody()), + deps({ + apply: async () => { + throw new RelayReportSaturatedError(); + }, + }), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "relay_report_saturated" }); + }); +}); + +describe("relay report concurrency slots", () => { + test("rejects work past the cap and recovers as slots settle", async () => { + const releases: Array<() => void> = []; + const held = Array.from( + { length: RELAY_REPORT_MAX_CONCURRENT }, + () => withRelayReportSlot( + () => new Promise((resolve) => releases.push(resolve)), + ), + ); + await Promise.resolve(); + await expect(withRelayReportSlot(async () => "over")).rejects.toThrow( + RelayReportSaturatedError, + ); + for (const release of releases) release(); + await Promise.all(held); + expect(await withRelayReportSlot(async () => "recovered")).toBe("recovered"); + }); +}); + +describe("relay report parsing and trust mapping", () => { + test("normalizes case and preserves millisecond timestamps", () => { + const parsed = parseRelayAttachReport({ + endpointId: ENDPOINT_ID.toUpperCase(), + event: "attach", + relayId: MANAGED_HOSTNAME.toUpperCase(), + ts: 1_756_100_000_123, + }, new Date(1_756_100_000_500)); + expect(parsed).toEqual({ + ok: true, + report: { + endpointId: ENDPOINT_ID, + event: "attach", + relayId: MANAGED_HOSTNAME, + reportedAt: new Date(1_756_100_000_123), + }, + }); + }); + + test("maps a managed hostname to its exact catalog URL", () => { + expect(publishableRelayURLForHostname(MANAGED_HOSTNAME, [])).toBe(MANAGED_URL); + }); + + test("maps a saved custom hostname to the saved URL verbatim", () => { + expect(publishableRelayURLForHostname( + "relay.corp.example", + ["https://relay.corp.example:8443/"], + )).toBe("https://relay.corp.example:8443/"); + }); + + test("refuses hostnames outside the catalog and the saved set", () => { + expect(publishableRelayURLForHostname("cmux-relay-dev", [])).toBeNull(); + expect(publishableRelayURLForHostname( + "evil.example", + ["https://relay.corp.example:8443/"], + )).toBeNull(); + }); + + test("the catalog wins over a saved custom relay with the same hostname", () => { + expect(publishableRelayURLForHostname( + MANAGED_HOSTNAME, + [`https://${MANAGED_HOSTNAME}:8443/`], + )).toBe(MANAGED_URL); + }); +}); diff --git a/web/tests/relay-token-route.test.ts b/web/tests/relay-token-route.test.ts deleted file mode 100644 index fc74d9a24bfe..000000000000 --- a/web/tests/relay-token-route.test.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - generateKeyPairSync, - verify as edVerify, -} from "node:crypto"; - -import { - handleRelayTokenRequest, - type RelayTokenDeps, -} from "../app/api/relay/token/route"; -import type { RelayPolicyPayload } from "../services/relay/model"; -import { mintManagedRelayCredentials } from "../services/relay/token"; -import type { AuthedUser } from "../services/vms/auth"; - -const { privateKey, publicKey } = generateKeyPairSync("ed25519"); -const ENDPOINT_ID = "0123456789abcdef".repeat(4); -const PAYLOAD: RelayPolicyPayload = { - version: 1, - jti: "01890f47-9ff8-7cc2-98b3-2fefdbb4312c", - sequence: 4, - iat: 1_700_000_000, - nbf: 1_700_000_000, - exp: 1_700_000_300, - aud: "cmux-iroh-relay-policy", - relay_protocol: "iroh-relay-v1", - relays: [{ - id: "managed-one", - provider: "cmux", - region: "us-west", - url: "https://relay-one.cmux.dev/", - }], -}; - -function deps(overrides: Partial = {}): RelayTokenDeps { - return { - verifyRequest: async () => ({ id: "account-a" }) as AuthedUser, - signingKey: () => privateKey, - nowSeconds: () => 1_700_000_000, - signedPolicy: async (accountId) => { - expect(accountId).toBe("account-a"); - return { - policy: "signed.policy.value", - payload: PAYLOAD, - preference: { - mode: "managed", - selectedManagedRelayIds: ["managed-one"], - customRelays: [], - }, - preferenceRevision: 3, - }; - }, - issueCredentials: (input) => mintManagedRelayCredentials({ - sub: input.accountId, - endpointId: input.endpointId, - relayUrls: input.relayUrls, - key: input.key, - nowSeconds: input.nowSeconds, - }), - isEndpointAuthorized: async () => true, - checkRateLimit: async () => ({ rateLimited: false }), - rateLimitRuleId: () => undefined, - isVercel: () => false, - credentialSigningRequired: () => false, - ...overrides, - }; -} - -function request( - body: unknown, - clientNamespace?: string, - includesBindingProof = false, -): Request { - return new Request("https://cmux.dev/api/relay/token", { - method: "POST", - headers: { - "content-type": "application/json", - ...(clientNamespace - ? { "x-cmux-app-namespace": clientNamespace } - : {}), - ...(includesBindingProof - ? { - "x-cmux-iroh-binding-id": "123e4567-e89b-42d3-a456-426614174090", - "x-cmux-iroh-request-time": "1700000000", - "x-cmux-iroh-request-signature": "a".repeat(86), - } - : {}), - }, - body: JSON.stringify(body), - }); -} - -describe("POST /api/relay/token", () => { - test("keeps legacy token fields and adds policy plus separate preference metadata", async () => { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps(), - ); - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("no-store"); - const body = await response.json() as Record; - expect(body.relays).toEqual(["https://relay-one.cmux.dev/"]); - expect(body.endpointId).toBe(ENDPOINT_ID); - expect(body.relayCredentials).toEqual([{ - relayUrl: "https://relay-one.cmux.dev/", - token: body.token, - expiresAt: 1_700_000_300, - refreshAfter: 1_700_000_240, - ttlSeconds: 300, - }]); - expect(body.policy).toBe("signed.policy.value"); - expect(body.preference).toEqual({ - mode: "managed", - selectedManagedRelayIds: ["managed-one"], - customRelays: [], - }); - expect(body.preferenceRevision).toBe(3); - expect(body.ttlSeconds).toBe(300); - expect(body.expiresAt).toBe(1_700_000_300); - - const [header, payload, signature] = (body.token as string).split("."); - expect(edVerify( - null, - Buffer.from(`${header}.${payload}`), - publicKey, - Buffer.from(signature, "base64url"), - )).toBe(true); - expect(JSON.parse(Buffer.from(payload, "base64url").toString())).toEqual({ - iss: "cmux", - aud: "cmux-relay", - sub: "account-a", - iat: 1_700_000_000, - exp: 1_700_000_300, - endpoint_id: ENDPOINT_ID, - }); - }); - - test("withholds relay credentials until the endpoint has an active broker binding", async () => { - let mintedCredentials = false; - const unboundDeps = { - ...deps({ - issueCredentials: (input) => { - mintedCredentials = true; - return mintManagedRelayCredentials({ - sub: input.accountId, - endpointId: input.endpointId, - relayUrls: input.relayUrls, - key: input.key, - nowSeconds: input.nowSeconds, - }); - }, - }), - isEndpointAuthorized: async (input: { - accountId: string; - endpointId: string; - clientNamespace: string; - nowSeconds: number; - bindingProof: unknown; - }) => { - expect(input).toEqual({ - accountId: "account-a", - endpointId: ENDPOINT_ID, - clientNamespace: "legacy", - nowSeconds: 1_700_000_000, - bindingProof: undefined, - }); - return false; - }, - }; - - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - unboundDeps, - ); - - expect(response.status).toBe(200); - expect(mintedCredentials).toBe(false); - const body = await response.json() as Record; - expect(body.endpointId).toBe(ENDPOINT_ID); - expect(body.policy).toBe("signed.policy.value"); - expect(body.preferenceRevision).toBe(3); - expect(body.relayCredentials).toBeUndefined(); - expect(body.token).toBeUndefined(); - expect(body.relays).toBeUndefined(); - expect(body.expiresAt).toBeUndefined(); - expect(body.ttlSeconds).toBeUndefined(); - }); - - test("passes the exact app namespace into endpoint ownership checks", async () => { - let checkedNamespace = ""; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }, "dev.cmux.app.beta", true), - deps({ - isEndpointAuthorized: async (input) => { - checkedNamespace = input.clientNamespace; - return false; - }, - }), - ); - - expect(response.status).toBe(403); - expect(checkedNamespace).toBe("dev.cmux.app.beta"); - const body = await response.json() as Record; - expect(body.error).toBe("invalid_binding_request_proof"); - }); - - test("requires binding proof before accepting a namespaced endpoint claim", async () => { - let rateLimitChecks = 0; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }, "dev.cmux.app.beta"), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async () => { - rateLimitChecks += 1; - return { rateLimited: false }; - }, - }), - ); - - expect(response.status).toBe(403); - expect(await response.json()).toEqual({ - error: "binding_request_proof_required", - }); - expect(rateLimitChecks).toBe(1); - }); - - test("returns signed policy without private relay credentials in local development", async () => { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ signingKey: () => null }), - ); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - endpointId: ENDPOINT_ID, - policy: "signed.policy.value", - preference: { - mode: "managed", - selectedManagedRelayIds: ["managed-one"], - customRelays: [], - }, - preferenceRevision: 3, - }); - }); - - test("fails closed without the private relay signer in deployed runtimes", async () => { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - signingKey: () => null, - credentialSigningRequired: () => true, - }), - ); - - expect(response.status).toBe(503); - expect(await response.json()).toEqual({ - error: "relay_token_not_configured", - }); - }); - - test("preserves distinct URL-token associations without ambiguous legacy fields", async () => { - const secondRelay = { - id: "managed-two", - provider: "other", - region: "eu-west", - url: "https://relay-two.example/", - } as const; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - signedPolicy: async () => ({ - policy: "signed.policy.value", - payload: { ...PAYLOAD, relays: [...PAYLOAD.relays, secondRelay] }, - preference: { - mode: "automatic", - selectedManagedRelayIds: [], - customRelays: [], - }, - preferenceRevision: 4, - }), - issueCredentials: ({ relayUrls, nowSeconds }) => relayUrls.map( - (relayUrl, index) => ({ - relayUrl, - token: index === 0 ? "abc234" : "def567", - expiresAt: nowSeconds + 300 + index, - refreshAfter: nowSeconds + 240 + index, - ttlSeconds: 300, - }), - ), - }), - ); - - expect(response.status).toBe(200); - const body = await response.json() as Record; - expect(body.relayCredentials).toEqual([ - { - relayUrl: PAYLOAD.relays[0]?.url, - token: "abc234", - expiresAt: 1_700_000_300, - refreshAfter: 1_700_000_240, - ttlSeconds: 300, - }, - { - relayUrl: secondRelay.url, - token: "def567", - expiresAt: 1_700_000_301, - refreshAfter: 1_700_000_241, - ttlSeconds: 300, - }, - ]); - expect(body.token).toBeUndefined(); - expect(body.relays).toBeUndefined(); - }); - - test("omits legacy fields when otherwise shared credentials refresh differently", async () => { - const secondRelay = { - id: "managed-two", - provider: "other", - region: "eu-west", - url: "https://relay-two.example/", - } as const; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - signedPolicy: async () => ({ - policy: "signed.policy.value", - payload: { ...PAYLOAD, relays: [...PAYLOAD.relays, secondRelay] }, - preference: { - mode: "automatic", - selectedManagedRelayIds: [], - customRelays: [], - }, - preferenceRevision: 4, - }), - issueCredentials: ({ relayUrls, nowSeconds }) => relayUrls.map( - (relayUrl, index) => ({ - relayUrl, - token: "shared-token", - expiresAt: nowSeconds + 300, - refreshAfter: nowSeconds + 240 + index, - ttlSeconds: 300, - }), - ), - }), - ); - - expect(response.status).toBe(200); - const body = await response.json() as Record; - expect(body.relayCredentials).toHaveLength(2); - expect(body.token).toBeUndefined(); - expect(body.relays).toBeUndefined(); - }); - - test("rejects missing, duplicate, and substituted credential URLs", async () => { - for (const issueCredentials of [ - () => [], - ({ relayUrls, nowSeconds }: Parameters[0]) => [ - { - relayUrl: relayUrls[0]!, - token: "abc234", - expiresAt: nowSeconds + 300, - refreshAfter: nowSeconds + 240, - ttlSeconds: 300, - }, - { - relayUrl: relayUrls[0]!, - token: "def567", - expiresAt: nowSeconds + 300, - refreshAfter: nowSeconds + 240, - ttlSeconds: 300, - }, - ], - ({ nowSeconds }: Parameters[0]) => [{ - relayUrl: "https://attacker.example/", - token: "abc234", - expiresAt: nowSeconds + 300, - refreshAfter: nowSeconds + 240, - ttlSeconds: 300, - }], - ]) { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ issueCredentials }), - ); - expect(response.status).toBe(503); - expect(await response.json()).toEqual({ error: "relay_policy_unavailable" }); - } - }); - - test("requires native same-account authentication and a valid endpoint id", async () => { - const unauthorized = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ verifyRequest: async () => null }), - ); - expect(unauthorized.status).toBe(401); - - const invalid = await handleRelayTokenRequest( - request({ endpointId: "z-base-32-is-not-valid" }), - deps(), - ); - expect(invalid.status).toBe(400); - }); - - test("turns a transient Stack Auth throttle into a retryable response", async () => { - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - verifyRequest: async () => { - throw new AggregateError( - [new Error("Rate limited, no retry-after header received")], - "Stack Auth unavailable", - ); - }, - }), - ); - - expect(response.status).toBe(429); - expect(response.headers.get("retry-after")).toBe("60"); - expect(await response.json()).toEqual({ error: "rate_limited" }); - - const statusLimited = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - verifyRequest: async () => { - throw { status: 429, message: "Too many requests" }; - }, - }), - ); - expect(statusLimited.status).toBe(429); - - const unavailable = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - verifyRequest: async () => { - throw new Error("Stack Auth connection failed"); - }, - }), - ); - expect(unavailable.status).toBe(503); - expect(unavailable.headers.get("retry-after")).toBeNull(); - expect(await unavailable.json()).toEqual({ - error: "authentication_unavailable", - }); - }); - - test("blocks a valid relay request before calling Stack Auth when ingress is limited", async () => { - let authCalls = 0; - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - verifyRequest: async () => { - authCalls += 1; - return { id: "account-a" } as AuthedUser; - }, - checkRateLimit: async (_id, options) => { - expect(options.rateLimitKey).toBeUndefined(); - return { rateLimited: true }; - }, - }), - ); - - expect(response.status).toBe(429); - expect(response.headers.get("retry-after")).toBe("60"); - expect(authCalls).toBe(0); - }); - - test("rate limits per account and endpoint and fails closed", async () => { - let key: string | undefined; - let checks = 0; - const limited = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async (_id, options) => { - checks += 1; - key = options.rateLimitKey; - if (options.rateLimitKey === undefined) return { rateLimited: false }; - return { rateLimited: true }; - }, - }), - ); - expect(limited.status).toBe(429); - // Partitioned per device, protocol phase, and minute: a storming endpoint - // starves only its duplicate work, never bootstrap, renewal, or another - // phone, simulator, or tagged build. - expect(key).toBe( - `account-a:${ENDPOINT_ID.toLowerCase()}:credential:28333333`, - ); - expect(limited.headers.get("retry-after")).toBe("40"); - - // Malformed requests are rejected before the limiter and never consume - // the per-device budget. - const invalid = await handleRelayTokenRequest( - request({ endpointId: "not-an-endpoint" }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async () => { - checks += 1; - return { rateLimited: true }; - }, - }), - ); - expect(invalid.status).toBe(400); - expect(checks).toBe(2); - - const blocked = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async (_id, options) => options.rateLimitKey === undefined - ? { rateLimited: false } - : { rateLimited: false, error: "blocked" }, - }), - ); - expect(blocked.status).toBe(429); - - const unavailable = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async (_id, options) => { - if (options.rateLimitKey === undefined) return { rateLimited: false }; - throw new Error("firewall unreachable"); - }, - }), - ); - expect(unavailable.status).toBe(503); - }); - - test("gives fresh endpoint bootstrap and bound credential renewal separate minute budgets", async () => { - let nowSeconds = 1_700_000_000; - let endpointBound = false; - const consumedPartitions = new Set(); - const observedPartitions: string[] = []; - const protocolDeps = deps({ - nowSeconds: () => nowSeconds, - isEndpointAuthorized: async () => endpointBound, - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async (_id, options) => { - const partition = options.rateLimitKey ?? ""; - if (!partition) return { rateLimited: false }; - observedPartitions.push(partition); - const rateLimited = consumedPartitions.has(partition); - consumedPartitions.add(partition); - return { rateLimited }; - }, - }); - - const bootstrap = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - protocolDeps, - ); - expect(bootstrap.status).toBe(200); - expect((await bootstrap.json() as Record).relayCredentials) - .toBeUndefined(); - - endpointBound = true; - const credential = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - protocolDeps, - ); - expect(credential.status).toBe(200); - expect((await credential.json() as Record).relayCredentials) - .toHaveLength(1); - - const duplicate = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - protocolDeps, - ); - expect(duplicate.status).toBe(429); - expect(duplicate.headers.get("retry-after")).toBe("40"); - - nowSeconds += 60; - const renewal = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - protocolDeps, - ); - expect(renewal.status).toBe(200); - expect(new Set(observedPartitions).size).toBe(3); - }); - - test("skips rate limiting when no rule is configured", async () => { - // An unset rule id env var means the operator wants no rate limiting. - // This must mint credentials, not 503 every device off the relay network. - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ isVercel: () => true, rateLimitRuleId: () => undefined }), - ); - expect(response.status).toBe(200); - }); - - test("fails open when the configured rate-limit rule no longer exists", async () => { - // Vercel reports a deleted firewall rule as not-found. That is an operator - // action, not an outage, so the mint must proceed as if unlimited. - const response = await handleRelayTokenRequest( - request({ endpointId: ENDPOINT_ID }), - deps({ - isVercel: () => true, - rateLimitRuleId: () => "relay-token", - checkRateLimit: async () => ({ rateLimited: false, error: "not-found" }), - }), - ); - expect(response.status).toBe(200); - }); -}); diff --git a/web/tests/relay-token.test.ts b/web/tests/relay-token.test.ts deleted file mode 100644 index 218870414388..000000000000 --- a/web/tests/relay-token.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { generateKeyPairSync, verify as edVerify } from "node:crypto"; - -import { - RELAY_TOKEN_TTL_SECONDS, - isValidEndpointId, - mintRelayToken, - relaySigningKey, - relayUrls, -} from "../services/relay/token"; - -// Pure unit tests: no route/auth mocking, so nothing leaks into the shared -// bun-test module registry. A throwaway keypair stands in for the fleet — the -// public key verifies the minted token exactly as a relay would. -const { publicKey, privateKey } = generateKeyPairSync("ed25519"); -const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; - -// A valid 64-hex iroh EndpointId and a valid 52-char RFC 4648 base32 one -// (A-Z2-7; "a" == 0 decodes to a 32-byte value). -const HEX_ID = "0123456789abcdef".repeat(4); -const BASE32_ID = "a".repeat(52); - -function verifyJwt(token: string): { - header: Record; - payload: Record; - valid: boolean; -} { - const [h, p, s] = token.split("."); - const valid = edVerify( - null, - Buffer.from(`${h}.${p}`), - publicKey, - Buffer.from(s, "base64url"), - ); - return { - header: JSON.parse(Buffer.from(h, "base64url").toString()), - payload: JSON.parse(Buffer.from(p, "base64url").toString()), - valid, - }; -} - -beforeEach(() => { - process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = privatePem; - delete process.env.CMUX_RELAY_URLS; -}); - -describe("mintRelayToken", () => { - test("mints an EdDSA JWT that verifies against the matching public key", () => { - const key = relaySigningKey(); - expect(key).not.toBeNull(); - const now = 1_700_000_000; - const { token, expiresAt } = mintRelayToken({ - sub: "user_abc", - endpointId: HEX_ID, - key: key!, - nowSeconds: now, - }); - const { header, payload, valid } = verifyJwt(token); - // Verifies against the PUBLIC key -> the relay would accept it. - expect(valid).toBe(true); - expect(header.alg).toBe("EdDSA"); - expect(header.typ).toBe("JWT"); - expect(payload.iss).toBe("cmux"); - expect(payload.aud).toBe("cmux-relay"); - expect(payload.sub).toBe("user_abc"); - expect(payload.iat).toBe(now); - expect(payload.exp).toBe(now + RELAY_TOKEN_TTL_SECONDS); - expect(expiresAt).toBe(now + RELAY_TOKEN_TTL_SECONDS); - // endpoint_id is always bound. - expect(payload.endpoint_id).toBe(HEX_ID); - }); - - test("lowercases the bound endpoint_id", () => { - const key = relaySigningKey()!; - const { token } = mintRelayToken({ - sub: "user_1", - endpointId: HEX_ID.toUpperCase(), - key, - nowSeconds: 1_700_000_000, - }); - const { payload } = verifyJwt(token); - expect(payload.endpoint_id).toBe(HEX_ID); - }); - - test("a token signed by a different key does NOT verify", () => { - const key = relaySigningKey()!; - const { token } = mintRelayToken({ - sub: "user_1", - endpointId: BASE32_ID, - key, - nowSeconds: 1_700_000_000, - }); - const other = generateKeyPairSync("ed25519").publicKey; - const [h, p, s] = token.split("."); - const valid = edVerify( - null, - Buffer.from(`${h}.${p}`), - other, - Buffer.from(s, "base64url"), - ); - expect(valid).toBe(false); - }); -}); - -describe("relaySigningKey", () => { - test("returns null when the PEM is unset or malformed", () => { - delete process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; - expect(relaySigningKey()).toBeNull(); - process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = "not a pem"; - expect(relaySigningKey()).toBeNull(); - }); - - test("returns null for a non-Ed25519 key (RSA)", () => { - const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); - process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM = rsa.privateKey.export({ - type: "pkcs8", - format: "pem", - }) as string; - expect(relaySigningKey()).toBeNull(); - }); -}); - -describe("isValidEndpointId", () => { - test("accepts exact 64-hex and 52-char RFC 4648 base32 (any case)", () => { - expect(isValidEndpointId(HEX_ID)).toBe(true); - expect(isValidEndpointId(HEX_ID.toUpperCase())).toBe(true); - expect(isValidEndpointId(BASE32_ID)).toBe(true); - expect(isValidEndpointId(BASE32_ID.toUpperCase())).toBe(true); - }); - test("rejects wrong-length or out-of-alphabet ids", () => { - expect(isValidEndpointId("a".repeat(48))).toBe(false); // wrong length - expect(isValidEndpointId("a".repeat(63))).toBe(false); // 63 != 64 - expect(isValidEndpointId(`${HEX_ID}00`)).toBe(false); // 66 hex - expect(isValidEndpointId("g".repeat(64))).toBe(false); // 'g' not hex - // '1'/'8' are z-base-32 but NOT RFC 4648 base32, so must be rejected. - expect(isValidEndpointId("1".repeat(52))).toBe(false); - expect(isValidEndpointId("8".repeat(52))).toBe(false); - // Non-canonical final symbol (non-zero trailing bits) — iroh's decoder - // rejects it, so we must too (final char must be 'a' or 'q'). - expect(isValidEndpointId("a".repeat(51) + "b")).toBe(false); - expect(isValidEndpointId("a".repeat(51) + "q")).toBe(true); - expect(isValidEndpointId("has spaces!!")).toBe(false); - }); -}); - -describe("relayUrls", () => { - test("returns the canonical 7-region fleet", () => { - const urls = relayUrls(); - expect(urls).toContain("https://usw1.relay.cmux.dev/"); - expect(urls).toContain("https://use4.relay.cmux.dev/"); - expect(urls.length).toBe(7); - }); - test("does not allow a legacy environment override to substitute the fleet", () => { - process.env.CMUX_RELAY_URLS = "https://a.example.com, https://b.example.com"; - expect(relayUrls()).not.toContain("https://a.example.com"); - expect(relayUrls().length).toBe(7); - }); -});