From 7782171ce7487a58b989b0d9f828e2d19fa6f3a7 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:31:41 -0700 Subject: [PATCH 01/18] Fix cold signed-out Iroh identity cleanup --- .../MobileIrohRuntimeComposition.swift | 24 ++++++-- .../MobileIrohRuntimeCompositionTests.swift | 56 ++++++++++++++++--- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift index 28616d521a7f..71b236d2233d 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift @@ -262,6 +262,11 @@ public final class MobileIrohRuntimeComposition: private var selectedPathObservationTask: Task? private var irohSettingsContinuations: [UUID: AsyncStream.Continuation] = [:] private var observedAuthState: MobileIrohAuthState? + /// `nil` is a valid auth state, so ``observedAuthState`` alone cannot + /// distinguish a cold launch that has not been reconciled from a launch + /// that has already observed signed-out auth. Keep that distinction so a + /// stale endpoint identity is wiped before the first signed-in session. + private var hasObservedAuthState = false private var observedAccountID: String? { observedAuthState?.accountID } private var activeAccountID: String? private let diagnosticArchive = DiagnosticReportArchive.defaultArchive() @@ -587,6 +592,7 @@ public final class MobileIrohRuntimeComposition: connectivityInvalidationSubscriber = nil } authObservationTask?.cancel() + hasObservedAuthState = false authObservationTask = Task { @MainActor [weak self, weak auth] in guard let auth else { return } await self?.startNetworkPathObservation({ [weak self] in @@ -1096,7 +1102,7 @@ public final class MobileIrohRuntimeComposition: let fallbackAccountID = activeAccountID ?? observedAccountID ?? lastKnownBindingAccountID - observedAuthState = MobileIrohAuthState(accountID: nil) + recordObservedAuthState(MobileIrohAuthState(accountID: nil)) lifecycleRevision &+= 1 let revision = lifecycleRevision let previous = transitionTask @@ -1236,7 +1242,7 @@ public final class MobileIrohRuntimeComposition: } guard authStateRequiresReconcile(state) else { return } let previousObservedAccountID = observedAccountID - observedAuthState = state + recordObservedAuthState(state) let transition = scheduleReconcile( targetAccountID: state.accountID, eraseAccountState: state.accountID == nil @@ -1256,7 +1262,7 @@ public final class MobileIrohRuntimeComposition: guard authStateRequiresReconcile(state) else { return } let accountID = state.accountID let previousObservedAccountID = observedAccountID - observedAuthState = state + recordObservedAuthState(state) _ = scheduleReconcile( targetAccountID: accountID, eraseAccountState: accountID == nil @@ -1278,7 +1284,7 @@ public final class MobileIrohRuntimeComposition: } guard authStateRequiresReconcile(state) else { return } let previousObservedAccountID = observedAccountID - observedAuthState = state + recordObservedAuthState(state) _ = scheduleReconcile( targetAccountID: accountID, eraseAccountState: accountID == nil @@ -1289,6 +1295,11 @@ public final class MobileIrohRuntimeComposition: } private func authStateRequiresReconcile(_ state: MobileIrohAuthState) -> Bool { + // The first observation must always reconcile, including a signed-out + // `nil` state. Without this guard, a cold launch starts with both + // values nil and skips `wipeLocalState()`, retaining a stale endpoint + // identity until a later account transition. + guard hasObservedAuthState else { return true } guard observedAuthState == state else { return true } guard let accountID = state.accountID else { return false } guard runtime == nil, transitionTask == nil else { return false } @@ -2166,6 +2177,11 @@ public final class MobileIrohRuntimeComposition: clearRelayPolicyRuntimeState() } + private func recordObservedAuthState(_ state: MobileIrohAuthState) { + observedAuthState = state + hasObservedAuthState = true + } + private func enqueueFallbackRevocation( accountID: String?, bindingID: String? diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift index f2e7e02e1181..7de1f7b8ce53 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift @@ -119,6 +119,41 @@ struct MobileIrohRuntimeCompositionTests { #expect(await fixture.endpointFactory.bindCount() == 1) } + @Test + func coldSignedOutLaunchDeletesStaleEndpointIdentity() async throws { + let fixture = try await MobileIrohSignOutFixture.make( + initiallySignedOut: true + ) + + var current = try await fixture.identities.identity( + accountID: fixture.accountID, + appInstanceID: fixture.appInstanceID + ) + for _ in 0 ..< 20 where current == fixture.identity { + try await Task.sleep(for: .milliseconds(10)) + current = try await fixture.identities.identity( + accountID: fixture.accountID, + appInstanceID: fixture.appInstanceID + ) + } + + #expect(current != fixture.identity) + #expect(current.generation == 1) + #expect(await fixture.endpointFactory.bindCount() == 0) + #expect( + try await fixture.brokerCredentials.loadBinding( + accountID: fixture.accountID, + appInstanceID: fixture.appInstanceID + ) == nil + ) + #expect( + try await fixture.appInstances.appInstanceID( + accountID: fixture.accountID, + tag: fixture.tag + ) != fixture.appInstanceID + ) + } + @Test func activationSeedsCachedBindingProofBeforeRegistration() async throws { let fixture = try await MobileIrohSignOutFixture.make() @@ -1440,6 +1475,7 @@ private struct MobileIrohSignOutFixture { /// can observe the token source handed to each direct broker. static func make( resolvableDeviceID: Bool = true, + initiallySignedOut: Bool = false, tag: String = "test", discoveryCompatibilityPolicy: MobileMacBuildCompatibilityPolicy? = nil, brokerFactory: MobileIrohRuntimeComposition.BrokerFactory? = nil @@ -1543,10 +1579,12 @@ private struct MobileIrohSignOutFixture { includesDevAuth: false ) ) - try await auth.signInWithPassword( - email: "a@example.com", - password: "pw" - ) + if !initiallySignedOut { + try await auth.signInWithPassword( + email: "a@example.com", + password: "pw" + ) + } let outbox = CmxIrohPendingRevocationOutbox(secureStore: outboxStore) let endpointFactory = MobileIrohCountingEndpointFactory() @@ -1590,13 +1628,17 @@ private struct MobileIrohSignOutFixture { expectedPeerDeviceID: "123e4567-e89b-42d3-a456-426614174074", authorizationMode: .transportAdmission ) - await #expect(throws: CmxIrohClientRuntimeError.self) { - _ = try await composition.transport(for: request) + if !initiallySignedOut { + await #expect(throws: CmxIrohClientRuntimeError.self) { + _ = try await composition.transport(for: request) + } } let initialBindCount = await endpointFactory.bindCount() // A resolvable durable id activates and binds an endpoint; an // unavailable one defers activation before any endpoint is created. - if resolvableDeviceID { + if initiallySignedOut { + #expect(initialBindCount == 0) + } else if resolvableDeviceID { #expect(initialBindCount > 0) } else { #expect(initialBindCount == 0) From 6f83caf69f0d7245b9786bd49c8cd5b0203db3e7 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:33:26 -0700 Subject: [PATCH 02/18] test: cover disabled Iroh control idle timeout --- .../MobileHostConnectionLifecycleTests.swift | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/cmuxTests/MobileHostConnectionLifecycleTests.swift b/cmuxTests/MobileHostConnectionLifecycleTests.swift index bbc347bbf5e8..c051c8d9234e 100644 --- a/cmuxTests/MobileHostConnectionLifecycleTests.swift +++ b/cmuxTests/MobileHostConnectionLifecycleTests.swift @@ -189,6 +189,62 @@ extension MobileHostAuthorizationTests { service.debugResetMobileLifecycleStateForTesting() } + @Test func testIrohTransportCanDisableTheControlIdleTimeout() async throws { + let service = MobileHostService.shared + service.debugResetMobileLifecycleStateForTesting() + let registry = MobileHostConnectionRegistry.shared + for connection in registry.removeAll() { + await connection.close(reason: "test setup") + } + defer { + service.debugResetMobileLifecycleStateForTesting() + } + + let expiringTransport = ScriptedMobileHostByteTransport() + let authorization = try irohAdmissionContext() + let expiringTask = Task { + await MobileHostService.acceptTransport( + expiringTransport, + authorization: authorization, + idleTimeoutNanoseconds: 1_000_000, + isCurrent: { true } + ) + } + await waitForMobileHostConnectionCount(1) + try await expiringTransport.enqueue(Self.mobileHostStatusFrame(id: "expiring")) + _ = await expiringTransport.waitForSentBufferCount(1) + await expiringTransport.waitForCloseCount(1) + #expect( + await expiringTask.value == CmxIrohAdmittedConnectionExit( + lifecycle: .controlReadFailed, + failure: .timedOut + ) + ) + + let persistentTransport = ScriptedMobileHostByteTransport() + let persistentTask = Task { + await MobileHostService.acceptTransport( + persistentTransport, + authorization: authorization, + idleTimeoutNanoseconds: 0, + isCurrent: { true } + ) + } + await waitForMobileHostConnectionCount(1) + try await persistentTransport.enqueue(Self.mobileHostStatusFrame(id: "persistent")) + _ = await persistentTransport.waitForSentBufferCount(1) + try await Task.sleep(nanoseconds: 25_000_000) + + #expect(await persistentTransport.observedCloseCount() == 0) + #expect(registry.count == 1) + + await persistentTransport.finishReceiving() + _ = await persistentTask.value + for connection in registry.removeAll() { + await connection.close(reason: "test cleanup") + } + } + @Test func testMobileHostPublishesUsableSessionOnlyAfterWorkspaceAndEventReadiness() async throws { CmuxEventBus.shared.resetForTesting() defer { CmuxEventBus.shared.resetForTesting() } From e20e3ae13d93f1aef861822f40cf6dab02b49cf3 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:34:39 -0700 Subject: [PATCH 03/18] fix: keep Iroh host sessions alive when control is idle --- Sources/Mobile/MobileHostIrxRuntime.swift | 4 ++++ Sources/Mobile/MobileHostService.swift | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/Mobile/MobileHostIrxRuntime.swift b/Sources/Mobile/MobileHostIrxRuntime.swift index 789c44c44b51..68fe7bd32f0c 100644 --- a/Sources/Mobile/MobileHostIrxRuntime.swift +++ b/Sources/Mobile/MobileHostIrxRuntime.swift @@ -703,6 +703,10 @@ final class MobileHostIrxRuntime { authorization: .irohAdmission(admittedPeer), artifactTransfers: artifactRegistry, independentEventWriter: eventWriter, + // The bounded Iroh peer pool stays alive via transport keepalives. + // Control-idle timeout is for unowned legacy TCP connections and + // must not tear down a healthy multi-lane QUIC session. + idleTimeoutNanoseconds: 0, isCurrent: { [weak self] in let runtime = self return await MainActor.run { runtime?.generationToken == token } diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 2b60130a922b..6ad5df0f0303 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1364,6 +1364,7 @@ final class MobileHostService { authorization: MobileHostConnectionAuthorizationContext, artifactTransfers: MobileHostIrohArtifactTransferRegistry? = nil, independentEventWriter: (any MobileHostIndependentEventWriting)? = nil, + idleTimeoutNanoseconds: UInt64 = MobileHostConnection.defaultIdleTimeoutNanoseconds, promoteUsableSession: @escaping @Sendable () async -> Bool = { true }, remoteControlDisabledByPolicy: @escaping @Sendable () -> Bool = { MobileRemoteControlPolicy.isDisabled @@ -1394,6 +1395,7 @@ final class MobileHostService { let session = MobileHostConnection( id: id, transport: transport, + idleTimeoutNanoseconds: idleTimeoutNanoseconds, independentEventWriter: independentEventWriter, authorizeRequest: { request in await Self.connectionAuthorizationError( @@ -2034,7 +2036,7 @@ extension MobileHostService { actor MobileHostConnection { private static let maximumReceiveBufferByteCount = MobileSyncFrameCodec.defaultMaximumFrameByteCount + MobileSyncFrameCodec.headerByteCount private static let defaultFirstFrameTimeoutNanoseconds: UInt64 = 15 * 1_000_000_000 - private static let defaultIdleTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000 + fileprivate static let defaultIdleTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000 /// Bounded deadline for one control-lane event write. A peer that accepted /// the connection but stopped reading (TCP zero-window, QUIC flow-control /// stall) would otherwise pin the drain — and with it this connection's From 0b29eb87812d231d617af1082e67ddecb366c04b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:42:23 -0700 Subject: [PATCH 04/18] Prevent duplicate Iroh control lane redials --- .../IrxControlByteTransport.swift | 11 +++- .../MobileIrxRuntimeComposition.swift | 57 +++++++++++++------ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxControlByteTransport.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxControlByteTransport.swift index 9076bd0d08b6..0b0930af86d0 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxControlByteTransport.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxControlByteTransport.swift @@ -12,15 +12,22 @@ public import Foundation /// Session ownership belongs to ``IrxPeerEngine``. public actor IrxControlByteTransport: CmxByteTransport { public typealias Establish = @Sendable () async throws -> (IrxConnection, IrxLaneStream) + public typealias OnClose = @Sendable () async -> Void private let establish: Establish + private let onClose: OnClose? private var pair: (IrxConnection, IrxLaneStream)? private var connectInFlight: Task<(IrxConnection, IrxLaneStream), any Error>? private var isClosed = false - public init(closeCode: IrxCloseCode, establish: @escaping Establish) { + public init( + closeCode: IrxCloseCode, + establish: @escaping Establish, + onClose: OnClose? = nil + ) { _ = closeCode self.establish = establish + self.onClose = onClose } /// Wraps an already-established pair (host side). @@ -54,6 +61,7 @@ public actor IrxControlByteTransport: CmxByteTransport { // retiring RPC client look like a peer death to the engine. await lane.writer.finish() await lane.reader.stop() + await onClose?() } private func establishedPair() async throws -> (IrxConnection, IrxLaneStream) { @@ -72,6 +80,7 @@ public actor IrxControlByteTransport: CmxByteTransport { let established = try await task.value guard !isClosed else { await established.1.close() + await onClose?() throw IrxConnectionError.closed(nil) } pair = established diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 313957a6a57c..bd45201ea177 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -112,8 +112,11 @@ public actor MobileIrxRuntimeComposition { private var enginesByPeer: [String: IrxPeerEngine] = [:] /// Route material per peer, refreshed on every transport request. private var routesByPeer: [String: (relayURL: String?, directAddresses: [String])] = [:] - /// The control lane is single-consumer: one claim per admitted session. - private var claimedControlSessions: Set = [] + /// The control lane is single-consumer: one live transport owner per + /// admitted session. A second RPC client must not force a QUIC replacement + /// just to obtain the same lane; that creates a host-shutdown/redial storm + /// when foreground recovery and secondary aggregation overlap. + private var claimedControlSessions: [String: UUID] = [:] /// The events uni-lane accept is single-consumer per session too. private var claimedEventSessions: Set = [] @@ -1021,29 +1024,51 @@ public actor MobileIrxRuntimeComposition { for request: CmxByteTransportRequest ) async throws -> any CmxByteTransport { let peerHex = try peerTarget(for: request) - return IrxControlByteTransport(closeCode: .explicitRedial) { [weak self] in - guard let self else { - throw CompositionError.notSignedIn + let ownerID = UUID() + return IrxControlByteTransport( + closeCode: .explicitRedial, + establish: { [weak self] in + guard let self else { + throw CompositionError.notSignedIn + } + return try await self.claimControlLane( + peerHex: peerHex, + ownerID: ownerID + ) + }, + onClose: { [weak self] in + await self?.releaseControlLane(ownerID: ownerID) } - return try await self.claimControlLane(peerHex: peerHex) - } + ) } private func claimControlLane( - peerHex: String + peerHex: String, + ownerID: UUID ) async throws -> (IrxConnection, IrxLaneStream) { let engine = engine(forPeer: peerHex) - var session = try await engine.ensureSession(trigger: "control-transport") - if claimedControlSessions.contains(session.admit.session) { - // The live session's control lane already belongs to an earlier - // transport: this caller is a replacement client, so replace the - // session (one control owner per session, always). - session = try await engine.ensureSession( - explicit: true, trigger: "control-transport-replacement") + let session = try await engine.ensureSession(trigger: "control-transport") + if let existingOwner = claimedControlSessions[session.admit.session], + existingOwner != ownerID { + // One admitted session exposes one control lane. Returning a + // transient closed error lets the caller's normal bounded retry + // policy wait for the current owner to drain, while preserving the + // healthy QUIC session for the owner that already has the lane. + Self.journal.record( + "client-runtime", "control-lane-busy", + ["peer": peerHex.prefix(12).lowercased()] + ) + throw IrxConnectionError.closed(nil) } - claimedControlSessions.insert(session.admit.session) + claimedControlSessions[session.admit.session] = ownerID return (session.connection, session.control) } + + private func releaseControlLane(ownerID: UUID) { + claimedControlSessions = claimedControlSessions.filter { + $0.value != ownerID + } + } } /// Artifact lane over irx: bounded reads down, no upstream bytes. From 5f83ece96e6a684c571cc32fc655903f5e0e930a Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:00:09 -0700 Subject: [PATCH 05/18] Wire IRX direct and authenticated LAN paths --- .../CmxIrohDirectPorts.swift | 6 +- .../CmuxIrxTransport/IrxBrokerService.swift | 62 ++++++++- .../CmuxIrxTransport/IrxEndpoint.swift | 12 ++ .../IrxProtocolTests.swift | 46 +++++++ ...MobileHostIrxRuntime+SettingsControl.swift | 5 +- Sources/Mobile/MobileHostIrxRuntime.swift | 93 ++++++++++++-- ios/cmux/cmuxApp.swift | 1 + .../MobileIrxRuntimeComposition.swift | 120 ++++++++++++++++-- .../MobileIrohRuntimeCompositionTests.swift | 27 ++++ 9 files changed, 342 insertions(+), 30 deletions(-) diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDirectPorts.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDirectPorts.swift index 0219c6246524..b1ce08c146d0 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDirectPorts.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDirectPorts.swift @@ -37,8 +37,10 @@ public struct CmxIrohDirectPorts: Codable, Equatable, Sendable { /// /// Iroh may bind IPv4 and IPv6 independently. If one family reports more /// than one port, that family is omitted rather than guessing which private - /// coordinate is authoritative. - init?(localDirectAddresses: [String]) { + /// coordinate is authoritative. This is public so the IRX runtime can + /// publish the same broker contract as the legacy runtime without exposing + /// private IPs. + public init?(localDirectAddresses: [String]) { var ipv4Ports: Set = [] var ipv6Ports: Set = [] var ipv4WildcardPorts: Set = [] diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift index 5847789b5714..81054855ac6f 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift @@ -143,7 +143,12 @@ public actor IrxBrokerService { private let credentialCache: any IrxJSONCache private let grantCache: any IrxJSONCache<[String: IrxGrantSnapshot]> private var registrationInFlight: Task? - private var lastHintRegistered: (url: String?, at: Date)? + private var lastHintRegistered: ( + url: String?, + directAddresses: [String], + directPorts: CmxIrohDirectPorts?, + at: Date + )? private var lastDiscovery: CmxIrohDiscoveryResponse? private var lastDiscoveryAt: Date? /// Monotonic lifecycle fence. URLSession work can outlive task @@ -240,15 +245,25 @@ public actor IrxBrokerService { /// burned half its window. Same never-lapses guarantee, ~5x fewer writes. public func registerHintIfNeeded( pairingEnabled: Bool, - relayURLHint: String? + relayURLHint: String?, + directAddresses: [String] = [], + directPorts: CmxIrohDirectPorts? = nil ) async throws { + let publicDirectAddresses = Self.publicDirectAddressValues(directAddresses) if let last = lastHintRegistered, last.url == relayURLHint, + last.directAddresses == publicDirectAddresses, + last.directPorts == directPorts, Date().timeIntervalSince(last.at) < 15 * 60 { return } - _ = try await register(pairingEnabled: pairingEnabled, relayURLHint: relayURLHint) + _ = try await register( + pairingEnabled: pairingEnabled, + relayURLHint: relayURLHint, + directAddresses: publicDirectAddresses, + directPorts: directPorts + ) } /// Registers (or refreshes) this endpoint's binding. Single-flight; @@ -256,6 +271,7 @@ public actor IrxBrokerService { public func register( pairingEnabled: Bool, relayURLHint: String?, + directAddresses: [String] = [], directPorts: CmxIrohDirectPorts? = nil ) async throws -> IrxBindingSnapshot { let epoch = try beginOperation() @@ -266,6 +282,7 @@ public actor IrxBrokerService { try await self.registerOnce( pairingEnabled: pairingEnabled, relayURLHint: relayURLHint, + directAddresses: directAddresses, directPorts: directPorts, epoch: epoch ) @@ -278,6 +295,7 @@ public actor IrxBrokerService { private func registerOnce( pairingEnabled: Bool, relayURLHint: String?, + directAddresses: [String], directPorts: CmxIrohDirectPorts?, epoch: UInt64 ) async throws -> IrxBindingSnapshot { @@ -297,6 +315,20 @@ public actor IrxBrokerService { hints.append(hint) } } + let publicDirectAddresses = Self.publicDirectAddressValues(directAddresses) + let expiresAt = now.addingTimeInterval(30 * 60) + for address in publicDirectAddresses { + guard hints.count < 16, + let hint = try? CmxIrohPathHint( + kind: .directAddress, + value: address, + source: .native, + privacyScope: .publicInternet, + observedAt: now, + expiresAt: expiresAt + ) else { continue } + hints.append(hint) + } let secretKey = try CmxIrohSecretKey(bytes: identity.privateKeyData) let material = try CmxIrohIdentityMaterial( secretKey: secretKey, generation: configuration.identityGeneration) @@ -331,7 +363,12 @@ public actor IrxBrokerService { ) try requireCurrent(epoch) bindingCache.save(snapshot) - lastHintRegistered = (relayURLHint, Date()) + lastHintRegistered = ( + relayURLHint, + publicDirectAddresses, + directPorts, + Date() + ) let elapsedMs = (DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 journal.record( @@ -705,4 +742,21 @@ public actor IrxBrokerService { throw IrxBrokerServiceError.deactivated } } + + private static func publicDirectAddressValues(_ addresses: [String]) -> [String] { + let now = Date() + let expiresAt = now.addingTimeInterval(30 * 60) + var seen = Set() + return addresses.compactMap { address in + guard let hint = try? CmxIrohPathHint( + kind: .directAddress, + value: address, + source: .native, + privacyScope: .publicInternet, + observedAt: now, + expiresAt: expiresAt + ), seen.insert(hint.value).inserted else { return nil } + return hint.value + } + } } diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxEndpoint.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxEndpoint.swift index 60e8c16e6811..b6b2c865406b 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxEndpoint.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxEndpoint.swift @@ -108,6 +108,18 @@ public actor IrxEndpointSupervisor { return driver.addr().relayUrl() } + /// Returns the endpoint's current direct candidates. Iroh owns candidate + /// discovery and NAT traversal; IRX only exposes the observed values so + /// the host can publish safe public hints and the client can seed the + /// authenticated LAN fallback. The relay-only policy deliberately returns + /// no candidates. + public func localDirectAddresses() -> [String] { + guard configuration.pathMode != .relayOnly, + let driver, + !driver.isClosed() else { return [] } + return driver.addr().directAddresses() + } + /// One accepted inbound connection, routed by the ALPN the dialer spoke. public enum AcceptedInbound: Sendable { case irx(IrxConnection) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxProtocolTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxProtocolTests.swift index 89fb463d9347..ce787db383e8 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxProtocolTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxProtocolTests.swift @@ -154,3 +154,49 @@ struct IrxIdentityTests { try? FileManager.default.removeItem(at: dir) } } + +@Suite("endpoint path policy") +struct IrxEndpointPathPolicyTests { + @Test("automatic dials carry direct candidates and relay-only dials strip them") + func dialAddressPolicy() throws { + let identity = IrxIdentity( + privateKeyData: Data(repeating: 7, count: 32), + deviceID: "device-a", + appInstanceID: "instance-a" + ) + let directAddresses = ["127.0.0.1:58470", "[::1]:58470"] + let automatic = IrxEndpointSupervisor( + configuration: IrxEndpointConfiguration( + identity: identity, + pathMode: .automatic, + initialRemoteBiStreams: 0, + initialRemoteUniStreams: 0 + ), + journal: IrxJournal(subsystem: "dev.cmux.tests", category: "irx-paths") + ) + let relayOnly = IrxEndpointSupervisor( + configuration: IrxEndpointConfiguration( + identity: identity, + pathMode: .relayOnly, + initialRemoteBiStreams: 0, + initialRemoteUniStreams: 0 + ), + journal: IrxJournal(subsystem: "dev.cmux.tests", category: "irx-paths") + ) + + #expect( + try automatic.dialAddress( + peerEndpointIDHex: identity.endpointIDHex, + relayURL: "https://relay.example.com/", + directAddresses: directAddresses + ).directAddresses() == directAddresses + ) + #expect( + try relayOnly.dialAddress( + peerEndpointIDHex: identity.endpointIDHex, + relayURL: "https://relay.example.com/", + directAddresses: directAddresses + ).directAddresses().isEmpty + ) + } +} diff --git a/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift b/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift index 2469010327d4..c0fa37bbe062 100644 --- a/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift +++ b/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift @@ -247,8 +247,9 @@ extension MobileHostIrxRuntime { } } - /// Relay for now: direct paths are unwired in irx v1, so the only - /// attributable live path is the relay the endpoint homes on. + /// The control-plane status remains relay-attributed until Iroh reports a + /// selected direct path. Direct candidates are still advertised and + /// attempted by the transport in automatic mode. nonisolated static func settingsSelectedPath( phase: SettingsPhase, endpointOnline: Bool, diff --git a/Sources/Mobile/MobileHostIrxRuntime.swift b/Sources/Mobile/MobileHostIrxRuntime.swift index 68fe7bd32f0c..16067f203a4f 100644 --- a/Sources/Mobile/MobileHostIrxRuntime.swift +++ b/Sources/Mobile/MobileHostIrxRuntime.swift @@ -97,6 +97,10 @@ final class MobileHostIrxRuntime { /// Durable home of the lease (Keychain in Release, dev file store in /// DEBUG), loaded at activation so admission works offline. private var deviceListStore: IrxDeviceListStore? + /// Authenticated Bonjour publisher for the IRX endpoint. Iroh's native + /// candidate discovery handles public paths, while this publisher makes + /// same-account LAN candidates available to the client-side fallback. + private let lanPublisher = CmxIrohLANHostPublisher() func configure(auth: AuthCoordinator) { self.auth = auth @@ -346,7 +350,7 @@ final class MobileHostIrxRuntime { ) localBinding = binding let credentials = try await pilot.usableCredentials() - _ = try await broker.discover() + let initialDiscovery = try await broker.discover() noteLiveDiscoverySucceeded() guard generationToken == token else { return } @@ -354,10 +358,41 @@ final class MobileHostIrxRuntime { // Advertise the relay the endpoint ACTUALLY homes on, then // refresh the binding so registry consumers see it too. let homeRelay = await supervisor.homeRelayURL() ?? credentials.first?.relayURL - _ = try? await broker.register(pairingEnabled: true, relayURLHint: homeRelay) + let directAddresses = await supervisor.localDirectAddresses() + let directPorts = CmxIrohDirectPorts(localDirectAddresses: directAddresses) + _ = try? await broker.register( + pairingEnabled: true, + relayURLHint: homeRelay, + directAddresses: directAddresses, + directPorts: directPorts + ) if let control, let homeRelay { await control.publishHint(homeRelayURL: homeRelay) } + let liveDiscovery = (try? await broker.discover(maximumAge: 0)) ?? initialDiscovery + if !Self.forceRelayOnly, + MobileHostService.isListeningEnabled, + let discoveredBinding = liveDiscovery.bindings.first(where: { + $0.endpointID.endpointID == identity.endpointIDHex + }), + let bindingMetadata = try? CmxIrohBrokerBindingMetadata( + bindingID: discoveredBinding.bindingID, + deviceID: discoveredBinding.deviceID, + appInstanceID: discoveredBinding.appInstanceID, + clientNamespace: discoveredBinding.clientNamespace, + tag: discoveredBinding.tag, + platform: discoveredBinding.platform, + endpointID: discoveredBinding.endpointID, + identityGeneration: discoveredBinding.identityGeneration, + pathHints: discoveredBinding.pathHints + ) + { + await lanPublisher.activate( + rendezvous: liveDiscovery.lanRendezvous, + binding: bindingMetadata, + directAddresses: { await supervisor.localDirectAddresses() } + ) + } // Relay hints are server-capped at 1h; refresh the registration on // every credential rotation so the advertised hint never expires, // and announce it over the socket so phones hear about relay @@ -365,11 +400,23 @@ final class MobileHostIrxRuntime { await pilot.setOnRotation { [weak self, weak broker, weak supervisor] in guard let broker, let supervisor else { return } let relay = await supervisor.homeRelayURL() + let directAddresses = await supervisor.localDirectAddresses() + let directPorts = CmxIrohDirectPorts(localDirectAddresses: directAddresses) try? await broker.registerHintIfNeeded( - pairingEnabled: true, relayURLHint: relay) + pairingEnabled: true, + relayURLHint: relay, + directAddresses: directAddresses, + directPorts: directPorts + ) if let relay, let control { await control.publishHint(homeRelayURL: relay) } + await self?.lanPublisher.refresh() + await self?.publishRoute( + identity: identity, + relayURL: relay, + directAddresses: directAddresses + ) // Credential rotation (and any home-relay move it reveals) // changes the Settings snapshot's policy expiry and relay // selection; push it to live subscribers. @@ -377,7 +424,11 @@ final class MobileHostIrxRuntime { } await pilot.start() - publishRoute(identity: identity, relayURL: homeRelay) + publishRoute( + identity: identity, + relayURL: homeRelay, + directAddresses: directAddresses + ) startAcceptLoop(token: token) Self.journal.record( "host-runtime", "active", @@ -418,6 +469,7 @@ final class MobileHostIrxRuntime { await autopilot.stop() } autopilot = nil + await lanPublisher.stop() if let registry { await registry.closeAll(code: .hostShutdown) } @@ -532,10 +584,15 @@ final class MobileHostIrxRuntime { } /// Publishes the irx endpoint as THE iroh route: attach tickets, host - /// status, and presence all advertise it, so phones dial irx. v1 hints - /// carry the relay URL only (relay-first; private hints require network - /// profiles the irx runtime deliberately does not synthesize yet). - private func publishRoute(identity: IrxIdentity, relayURL: String?) { + /// status, and presence all advertise it, so phones dial irx. Relay and + /// validated public direct hints are published here. Private LAN + /// candidates stay on the authenticated Bonjour path and are never copied + /// into the public status route. + private func publishRoute( + identity: IrxIdentity, + relayURL: String?, + directAddresses: [String] = [] + ) { guard let peerIdentity = try? CmxIrohPeerIdentity(endpointID: identity.endpointIDHex) else { return } var hints: [CmxIrohPathHint] = [] @@ -552,10 +609,28 @@ final class MobileHostIrxRuntime { { hints.append(hint) } + if !Self.forceRelayOnly { + for address in directAddresses { + guard hints.count < 16, + let hint = try? CmxIrohPathHint( + kind: .directAddress, + value: address, + source: .native, + privacyScope: .publicInternet, + observedAt: now, + expiresAt: now.addingTimeInterval(30 * 60) + ) else { continue } + if !hints.contains(hint) { hints.append(hint) } + } + } MobileHostPublicStatusCache.update(irohIdentity: peerIdentity, pathHints: hints) Self.journal.record( "host-runtime", "route-published", - ["hints": String(hints.count), "relay": relayURL ?? "-"] + [ + "hints": String(hints.count), + "direct": String(hints.count { $0.kind == .directAddress }), + "relay": relayURL ?? "-", + ] ) } diff --git a/ios/cmux/cmuxApp.swift b/ios/cmux/cmuxApp.swift index 005491b32958..d4eb8063b333 100644 --- a/ios/cmux/cmuxApp.swift +++ b/ios/cmux/cmuxApp.swift @@ -64,6 +64,7 @@ struct cmuxApp: App { let irxEnabled = MobileIrxRuntimeComposition.isEnabled let irx = MobileIrxRuntimeComposition( apiBaseURL: auth.config.apiBaseURL, + reachability: reachability, appNamespace: auth.appNamespace, keychainAccessGroup: auth.keychainAccessGroup ) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index bd45201ea177..b95ca2a0827e 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -4,6 +4,7 @@ public import CmuxIrohTransport import CmuxIrxTransport public import CmuxMobileRPC import CmuxMobileShellModel +import CmuxMobileTransport public import Foundation /// iOS composition root for the irx transport (the from-scratch iroh rebuild @@ -80,6 +81,11 @@ public actor MobileIrxRuntimeComposition { /// The app's signed Keychain access group; scopes the Release device-list /// and broker-cache Keychain items. private let keychainAccessGroup: String? + /// Shared with the legacy path provider so IRX can authorize and expire + /// authenticated Bonjour LAN profiles on the same network generations. + private let networkPathState: MobileIrohNetworkPathState + private let lanPeerDiscovery: CmxIrohLANPeerDiscovery + private let reachability: (any ReachabilityProviding)? private weak var auth: AuthCoordinator? /// Identity donor (identity adoption): the legacy composition owns the @@ -112,6 +118,10 @@ public actor MobileIrxRuntimeComposition { private var enginesByPeer: [String: IrxPeerEngine] = [:] /// Route material per peer, refreshed on every transport request. private var routesByPeer: [String: (relayURL: String?, directAddresses: [String])] = [:] + /// The LAN resolver authenticates an mDNS result against the device ID as + /// well as the endpoint key. Attach routes carry that intent separately + /// from the cryptographic peer identity. + private var expectedDeviceIDByPeer: [String: String] = [:] /// The control lane is single-consumer: one live transport owner per /// admitted session. A second RPC client must not force a QUIC replacement /// just to obtain the same lane; that creates a host-shutdown/redial storm @@ -123,13 +133,34 @@ public actor MobileIrxRuntimeComposition { @MainActor public init( apiBaseURL: String, + reachability: (any ReachabilityProviding)? = nil, infoDictionary: [String: Any]? = Bundle.main.infoDictionary, bundleIdentifier: String? = Bundle.main.bundleIdentifier, appNamespace injectedAppNamespace: MobileIOSAppNamespace? = nil, keychainAccessGroup: String? = nil, defaults: UserDefaults = .standard ) { + let networkPathState = MobileIrohNetworkPathState() + let lanPeerDiscovery = CmxIrohLANPeerDiscovery( + networkPath: { await networkPathState.snapshot() }, + authorizeProfile: { profile, generation, interfaceIndex in + await networkPathState.authorizeLANProfile( + profile, + generation: generation, + interfaceIndex: interfaceIndex + ) + }, + revokeProfile: { profile, generation in + await networkPathState.revokeLANProfile( + profile, + generation: generation + ) + } + ) self.keychainAccessGroup = keychainAccessGroup + self.networkPathState = networkPathState + self.lanPeerDiscovery = lanPeerDiscovery + self.reachability = reachability _ = defaults let appNamespace = injectedAppNamespace ?? MobileIOSAppNamespace(bundleIdentifier: bundleIdentifier) @@ -169,6 +200,18 @@ public actor MobileIrxRuntimeComposition { self.auth = auth legacyComposition = legacy self.controlPlaneBaseURL = controlPlaneBaseURL + if let reachability { + let networkPathState = self.networkPathState + let lanPeerDiscovery = self.lanPeerDiscovery + Task { + await networkPathState.start( + reachability: reachability, + onPathChange: { + await lanPeerDiscovery.pathDidChange() + } + ) + } + } Self.journal.record( "client-runtime", "configured", [ @@ -278,6 +321,7 @@ public actor MobileIrxRuntimeComposition { public func didBecomeActive() async { await autopilot?.kick() await controlPlane?.kick() + await lanPeerDiscovery.permissionMayHaveChanged() for engine in enginesByPeer.values { await engine.foregroundKick() } @@ -456,6 +500,7 @@ public actor MobileIrxRuntimeComposition { for engine in engines { await engine.stop(code: .userRequested) } + await lanPeerDiscovery.stop() // irx adopts the legacy identity repository, so the donor must run its // sign-out preparation even when legacy transport is dormant. This is // what removes the Ed25519 endpoint key and queues any binding revoke @@ -469,6 +514,7 @@ public actor MobileIrxRuntimeComposition { await broker?.deactivate() identity = nil routesByPeer.removeAll() + expectedDeviceIDByPeer.removeAll() claimedControlSessions.removeAll() claimedEventSessions.removeAll() @@ -785,6 +831,9 @@ public actor MobileIrxRuntimeComposition { // Attach tickets strip path hints, so a nil hint here is normal; // never clobber a relay already resolved from discovery with nil. let existing = routesByPeer[identity.endpointID] + if let expectedPeerDeviceID = request.expectedPeerDeviceID { + expectedDeviceIDByPeer[identity.endpointID] = expectedPeerDeviceID + } routesByPeer[identity.endpointID] = ( relayURL ?? existing?.relayURL, directAddresses.isEmpty ? (existing?.directAddresses ?? []) : directAddresses @@ -795,21 +844,27 @@ public actor MobileIrxRuntimeComposition { /// The target's home relay from the account registry: the Mac registers /// the relay its endpoint actually homes on, and dialing any OTHER relay /// is a black hole (the relay only forwards to peers connected to it). - private func relayHintFromDiscovery( + private func refreshRouteFromDiscovery( peerHex: String, broker: IrxBrokerService - ) async -> String? { - guard let discovery = try? await broker.discover() else { return nil } + ) async -> (binding: CmxIrohBrokerBinding, discovery: CmxIrohDiscoveryResponse)? { + guard let discovery = try? await broker.discover(maximumAge: 30), + let binding = discovery.bindings.first(where: { + $0.endpointID.endpointID == peerHex + }) else { return nil } let now = Date() - let hint = discovery.bindings - .first { $0.endpointID.endpointID == peerHex }? - .pathHints - .first { $0.kind == .relayURL && $0.isUsable(at: now) }? - .value - if let hint { - routesByPeer[peerHex] = (hint, routesByPeer[peerHex]?.directAddresses ?? []) - } - return hint + let relay = binding.pathHints.first { + $0.kind == .relayURL && $0.isUsable(at: now) + }?.value + let direct = binding.pathHints.filter { + $0.kind == .directAddress && $0.isUsable(at: now) + }.map(\.value) + let existing = routesByPeer[peerHex] + routesByPeer[peerHex] = ( + relay ?? existing?.relayURL, + direct.isEmpty ? (existing?.directAddresses ?? []) : direct + ) + return (binding, discovery) } private func engine(forPeer peerHex: String) -> IrxPeerEngine { @@ -864,8 +919,47 @@ public actor MobileIrxRuntimeComposition { try enforceDialGate(peerHex: peerHex) let credentials = try await autopilot.usableCredentials() var relayURL = routesByPeer[peerHex]?.relayURL + let discoveredRoute: (binding: CmxIrohBrokerBinding, discovery: CmxIrohDiscoveryResponse)? + if !Self.forceRelayOnly { + discoveredRoute = await refreshRouteFromDiscovery( + peerHex: peerHex, + broker: broker + ) + relayURL = routesByPeer[peerHex]?.relayURL + } else { + discoveredRoute = nil + } if relayURL == nil { - relayURL = await relayHintFromDiscovery(peerHex: peerHex, broker: broker) + relayURL = routesByPeer[peerHex]?.relayURL + } + if !Self.forceRelayOnly, + let discoveredRoute, + let expectedDeviceID = expectedDeviceIDByPeer[peerHex] + { + let authenticatedBindings = discoveredRoute.discovery.bindings.map { + CmxIrohBrokerBindingMetadata(binding: $0) + } + if case let .found(peers) = await lanPeerDiscovery.discover( + rendezvous: discoveredRoute.discovery.lanRendezvous, + authenticatedBindings: authenticatedBindings, + expectedMacDeviceID: expectedDeviceID, + expectedEndpointID: discoveredRoute.binding.endpointID + ) { + var direct = routesByPeer[peerHex]?.directAddresses ?? [] + for peer in peers where peer.binding.endpointID == discoveredRoute.binding.endpointID { + for hint in peer.pathHints where !direct.contains(hint.value) { + direct.append(hint.value) + } + } + routesByPeer[peerHex] = (relayURL, direct) + Self.journal.record( + "client-dial", "lan-hints-adopted", + [ + "peer": String(peerHex.prefix(12)), + "count": String(direct.count), + ] + ) + } } Self.journal.record( "client-dial", "target-resolved", diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift index 7de1f7b8ce53..2a3460302f86 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrohRuntimeCompositionTests.swift @@ -154,6 +154,33 @@ struct MobileIrohRuntimeCompositionTests { ) } + @Test + func explicitSignOutDeletesEndpointIdentityBeforeNextUse() async throws { + let fixture = try await MobileIrohSignOutFixture.make() + let irx = MobileIrxRuntimeComposition( + apiBaseURL: "https://cmux.com", + infoDictionary: [ + "CMUXAuthEnvironment": "production", + "CMUXDevTag": fixture.tag, + ], + bundleIdentifier: "dev.cmux.ios", + defaults: fixture.debugDefaults + ) + await irx.configure(auth: fixture.auth, legacy: fixture.composition) + + await irx.handleSignOut() + + // Ask the identity repository for the old scope after sign-out. A + // deleted key must not be returned, even if a caller still has the + // pre-sign-out app-instance identifier in memory. + let replacement = try await fixture.identities.identity( + accountID: fixture.accountID, + appInstanceID: fixture.appInstanceID + ) + #expect(replacement != fixture.identity) + #expect(replacement.generation == 1) + } + @Test func activationSeedsCachedBindingProofBeforeRegistration() async throws { let fixture = try await MobileIrohSignOutFixture.make() From ca5fff9215cff14b23720188ac1047782779598e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:09:02 -0700 Subject: [PATCH 06/18] Honor explicit IRX direct dial allowlists --- .../MobileIrxRuntimeComposition.swift | 119 ++++++++++++++---- 1 file changed, 94 insertions(+), 25 deletions(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index b95ca2a0827e..a4565ad5e2de 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -42,6 +42,7 @@ public actor MobileIrxRuntimeComposition { case notSignedIn case unsupportedRoute case peerNotDiscovered + case directDialUnavailable } /// Dial-gate refusals from the device-list lease. Deliberately NOT @@ -118,6 +119,12 @@ public actor MobileIrxRuntimeComposition { private var enginesByPeer: [String: IrxPeerEngine] = [:] /// Route material per peer, refreshed on every transport request. private var routesByPeer: [String: (relayURL: String?, directAddresses: [String])] = [:] + /// The latest path intent for each peer. Direct is an exclusive, + /// fail-closed allowlist; automatic permits broker and LAN discovery. + private var dialIntentByPeer: [String: IrxDialIntent] = [:] + /// The intent used by the currently admitted session. A request that + /// changes intent explicitly replaces the session before reusing it. + private var activeDialIntentByPeer: [String: IrxDialIntent] = [:] /// The LAN resolver authenticates an mDNS result against the device ID as /// well as the endpoint key. Attach routes carry that intent separately /// from the cryptographic peer identity. @@ -514,6 +521,8 @@ public actor MobileIrxRuntimeComposition { await broker?.deactivate() identity = nil routesByPeer.removeAll() + dialIntentByPeer.removeAll() + activeDialIntentByPeer.removeAll() expectedDeviceIDByPeer.removeAll() claimedControlSessions.removeAll() claimedEventSessions.removeAll() @@ -838,9 +847,49 @@ public actor MobileIrxRuntimeComposition { relayURL ?? existing?.relayURL, directAddresses.isEmpty ? (existing?.directAddresses ?? []) : directAddresses ) + dialIntentByPeer[identity.endpointID] = request.irohDirectOnlyDialCandidates.map { + .direct($0) + } ?? .automatic return identity.endpointID } + private enum IrxDialIntent: Equatable, Sendable { + case automatic + case direct([CmxIrohDirectDialCandidate]) + } + + private static func directDialAddresses( + candidates: [CmxIrohDirectDialCandidate], + directPorts: CmxIrohDirectPorts? + ) -> [String] { + var seen = Set() + return candidates.prefix(16).compactMap { candidate in + guard let address = try? CmxIrohCustomPrivateAddress(candidate.address) else { + return nil + } + let port = candidate.port ?? ( + address.family == .ipv4 ? directPorts?.ipv4 : directPorts?.ipv6 + ) + guard let port, port != 0 else { return nil } + let value = address.socketAddress(port: port) + guard seen.insert(value).inserted else { return nil } + return value + } + } + + private func ensureSession( + forPeer peerHex: String, + trigger: String + ) async throws -> IrxClientSession { + let engine = engine(forPeer: peerHex) + let desired = dialIntentByPeer[peerHex] ?? .automatic + let replaceForIntent = activeDialIntentByPeer[peerHex].map { $0 != desired } ?? false + return try await engine.ensureSession( + explicit: replaceForIntent, + trigger: trigger + ) + } + /// The target's home relay from the account registry: the Mac registers /// the relay its endpoint actually homes on, and dialing any OTHER relay /// is a black hole (the relay only forwards to peers connected to it). @@ -918,21 +967,40 @@ public actor MobileIrxRuntimeComposition { } try enforceDialGate(peerHex: peerHex) let credentials = try await autopilot.usableCredentials() - var relayURL = routesByPeer[peerHex]?.relayURL + let dialIntent = dialIntentByPeer[peerHex] ?? .automatic + var relayURL: String? + var directAddresses: [String] = [] let discoveredRoute: (binding: CmxIrohBrokerBinding, discovery: CmxIrohDiscoveryResponse)? - if !Self.forceRelayOnly { - discoveredRoute = await refreshRouteFromDiscovery( - peerHex: peerHex, - broker: broker + switch dialIntent { + case let .direct(candidates): + // Port-less Direct candidates may use only the broker's current + // per-family UDP port. This reads metadata, never a broker path, + // and still fails closed if no usable pinned address remains. + let needsPublishedPorts = candidates.contains { $0.port == nil } + let route = needsPublishedPorts + ? await refreshRouteFromDiscovery(peerHex: peerHex, broker: broker) + : nil + discoveredRoute = route + directAddresses = Self.directDialAddresses( + candidates: candidates, + directPorts: route?.binding.directPorts ) + guard !directAddresses.isEmpty else { + Self.journal.record( + "client-dial", "direct-candidates-unusable", + ["peer": String(peerHex.prefix(12)), "count": String(candidates.count)] + ) + throw CompositionError.directDialUnavailable + } + case .automatic: + discoveredRoute = Self.forceRelayOnly + ? nil + : await refreshRouteFromDiscovery(peerHex: peerHex, broker: broker) relayURL = routesByPeer[peerHex]?.relayURL - } else { - discoveredRoute = nil - } - if relayURL == nil { - relayURL = routesByPeer[peerHex]?.relayURL + directAddresses = routesByPeer[peerHex]?.directAddresses ?? [] } if !Self.forceRelayOnly, + case .automatic = dialIntent, let discoveredRoute, let expectedDeviceID = expectedDeviceIDByPeer[peerHex] { @@ -945,12 +1013,13 @@ public actor MobileIrxRuntimeComposition { expectedMacDeviceID: expectedDeviceID, expectedEndpointID: discoveredRoute.binding.endpointID ) { - var direct = routesByPeer[peerHex]?.directAddresses ?? [] + var direct = directAddresses for peer in peers where peer.binding.endpointID == discoveredRoute.binding.endpointID { for hint in peer.pathHints where !direct.contains(hint.value) { direct.append(hint.value) } } + directAddresses = direct routesByPeer[peerHex] = (relayURL, direct) Self.journal.record( "client-dial", "lan-hints-adopted", @@ -966,10 +1035,11 @@ public actor MobileIrxRuntimeComposition { [ "peer": String(peerHex.prefix(12)), "relay": relayURL ?? "-", - "direct": String(routesByPeer[peerHex]?.directAddresses.count ?? 0), + "direct": String(directAddresses.count), + "intent": dialIntent == .automatic ? "automatic" : "direct", ] ) - if relayURL == nil { + if case .automatic = dialIntent, relayURL == nil { // Stale/missing hint (e.g. the Mac's registered hint lapsed): // fall back to our own relay rather than refusing outright; the // fleet is small enough that co-homing is common, and a wrong @@ -983,7 +1053,7 @@ public actor MobileIrxRuntimeComposition { let address = try supervisor.dialAddress( peerEndpointIDHex: peerHex, relayURL: relayURL, - directAddresses: routesByPeer[peerHex]?.directAddresses ?? [] + directAddresses: directAddresses ) let connection = try await supervisor.dial( address: address, credentials: credentials) @@ -998,9 +1068,10 @@ public actor MobileIrxRuntimeComposition { await connection.raiseRemoteStreamCredit(bi: 0, uni: 4) // Automatic path mode: authorize NAT traversal so iroh can upgrade // this session off the relay make-before-break (direct/LAN paths). - if !Self.forceRelayOnly { + if !Self.forceRelayOnly, case .automatic = dialIntent { await connection.authorizeDirectPaths() } + activeDialIntentByPeer[peerHex] = dialIntent return IrxClientSession( connection: connection, admit: admit, @@ -1015,8 +1086,7 @@ public actor MobileIrxRuntimeComposition { for request: CmxByteTransportRequest ) async throws -> CmxIndependentEventByteStream { let peerHex = try peerTarget(for: request) - let session = try await engine(forPeer: peerHex) - .ensureSession(trigger: "server-events") + let session = try await ensureSession(forPeer: peerHex, trigger: "server-events") guard !claimedEventSessions.contains(session.admit.session) else { throw CompositionError.unsupportedRoute } @@ -1056,8 +1126,7 @@ public actor MobileIrxRuntimeComposition { cursor: UInt64? = nil ) async throws -> MobileIrohTerminalLane { let peerHex = try peerTarget(for: request) - let session = try await engine(forPeer: peerHex) - .ensureSession(trigger: "terminal-lane") + let session = try await ensureSession(forPeer: peerHex, trigger: "terminal-lane") let lane = try await session.connection.openLane( IrxLaneDescriptor( lane: .terminal, @@ -1081,8 +1150,7 @@ public actor MobileIrxRuntimeComposition { offset: UInt64 ) async throws -> any MobileArtifactLaneConnection { let peerHex = try peerTarget(for: request) - let session = try await engine(forPeer: peerHex) - .ensureSession(trigger: "artifact-lane") + let session = try await ensureSession(forPeer: peerHex, trigger: "artifact-lane") let lane = try await session.connection.openLane( IrxLaneDescriptor(lane: .artifact, resource: resourceID, offset: offset) ) @@ -1094,8 +1162,10 @@ public actor MobileIrxRuntimeComposition { panelID: UUID ) async throws -> MobileIrohSimulatorStreamLane { let peerHex = try peerTarget(for: request) - let session = try await engine(forPeer: peerHex) - .ensureSession(trigger: "simulator-stream-lane") + let session = try await ensureSession( + forPeer: peerHex, + trigger: "simulator-stream-lane" + ) // Same legacy resource dialect the terminal lane uses; the Mac's // dialect server routes it to MobileHostIrohSimulatorStreamLaneHandler. let lane = try await session.connection.openLane( @@ -1140,8 +1210,7 @@ public actor MobileIrxRuntimeComposition { peerHex: String, ownerID: UUID ) async throws -> (IrxConnection, IrxLaneStream) { - let engine = engine(forPeer: peerHex) - let session = try await engine.ensureSession(trigger: "control-transport") + let session = try await ensureSession(forPeer: peerHex, trigger: "control-transport") if let existingOwner = claimedControlSessions[session.admit.session], existingOwner != ownerID { // One admitted session exposes one control lane. Returning a From 7b0f06e9c4e72ce9a63bb8a9224f2f8282ef0089 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:24:21 -0700 Subject: [PATCH 07/18] docs: document IRX direct path support --- docs/iroh-app-transport-architecture.md | 8 ++++---- docs/irx-ci-test-plan.md | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/iroh-app-transport-architecture.md b/docs/iroh-app-transport-architecture.md index 539cf0a1cee6..67844d714c30 100644 --- a/docs/iroh-app-transport-architecture.md +++ b/docs/iroh-app-transport-architecture.md @@ -10,7 +10,7 @@ cmux uses Iroh for application sessions. It does not implement a general IP VPN An Iroh EndpointID is peer identity. IP addresses, relay URLs, Bonjour records, Tailscale addresses, and VPN addresses are reachability hints only. No hint can authorize a peer, select an account, or alter a grant. -The legacy Tailscale TCP transport remains during migration for released clients and current framed RPC only. It does not receive Iroh multistream, path-migration, priority, or per-lane cancellation features. New functionality uses Iroh. Explicit relayless Tailscale and custom-VPN Iroh bootstrap require separately implemented provider-bound profiles; the wire models alone do not constitute support. +The legacy Tailscale TCP transport remains during migration for released clients and current framed RPC only. It does not receive Iroh multistream, path-migration, priority, or per-lane cancellation features. New functionality uses Iroh. IRX Direct and Tailscale-only modes may use an explicit numeric candidate allowlist for an Iroh peer. The allowlist is user-pinned, fails closed, and never widens through relay or LAN discovery. Generic provider inference and unauthenticated private-network transport remain unsupported. ## Connection plan @@ -21,7 +21,7 @@ Production endpoints start from Iroh's `Minimal` preset and add only relays from cmux-supplied addresses have two explicit phases: 1. Try globally routable direct addresses and the managed relay fleet. After cmux admits the peer, Iroh may exchange its own NAT-traversal candidates and migrate this connection. -2. If bootstrap fails, try an authenticated Bonjour LAN hint for the exact known Mac. Tailscale and custom-private-network explicit hints remain disabled until a production provider can prove the active overlay and bind the attempted route to it. +2. If bootstrap fails, try an authenticated Bonjour LAN hint for the exact known Mac. A user-selected Direct or Tailscale-only Iroh route instead uses only its stored numeric allowlist, with the current authenticated Iroh UDP port joined at dial time when a candidate omits its port. An empty or invalid allowlist fails closed; no relay or LAN candidate is substituted. Private hints never enter the first cmux-supplied `EndpointAddr`. Iroh treats supplied IP paths as equivalent candidates, so array order is not a fallback boundary. @@ -29,7 +29,7 @@ This phase split is not a relay-only IP-privacy boundary. Stock Iroh 1.0 registe After activation, the admitted peer may learn LAN, Tailscale, or other interface addresses even when cmux supplied only a relay URL. cmux documents this behavior and does not claim peer-IP concealment from an admitted peer. Iroh 1.0 still has no relay-only connection mode. Managed deployments that require peer-IP concealment must disable Iroh until a separately tested relay-only mode exists. -For admitted online sessions, this in-band candidate exchange is the generic private-network integration: Iroh can discover a working LAN or VPN interface without cmux publishing private addresses through the broker or identifying a VPN vendor. It cannot help when relay and public-direct bootstrap both fail. Explicit provider-qualified private hints are reserved for that relayless/offline case and are not production-enabled for Tailscale or custom VPNs in v1. Tailscale raw TCP remains a released-client fallback, not the model for every private network. +For admitted online sessions, this in-band candidate exchange is the generic private-network integration: Iroh can discover a working LAN or VPN interface without cmux publishing private addresses through the broker or identifying a VPN vendor. It cannot help when relay and public-direct bootstrap both fail. The explicit Direct/Tailscale-only allowlist is the relayless or offline escape hatch, and it carries no bearer authorization beyond Iroh TLS and cmux admission. Tailscale raw TCP remains a released-client fallback, not the model for every private network. Path migration may move an established connection between relay and direct reachability without reopening application streams. cmux treats this as one connection and does not assume Iroh stripes bandwidth across paths. @@ -69,7 +69,7 @@ Offline LAN discovery is opt-in. The iOS target must declare its cmux Bonjour se | Iroh-discovered LAN, Tailscale, or VPN candidate | Supported after admission | Same connection may migrate direct; selection is opportunistic, not guaranteed. | | Authenticated Bonjour LAN Iroh bootstrap | Supported | Exact known EndpointID or one-use offline proof; numeric on-link addresses only. | | Numeric Tailscale TCP | Compatibility only | Current framed RPC after interface-bound route proof; no Iroh-only features. | -| Explicit relayless Tailscale/custom-VPN Iroh hint | Deferred | Models and tests exist, but no production provider/profile producer exists. | +| Explicit relayless Tailscale/custom-VPN Iroh hint | Supported when user-pinned | Numeric candidates only; current authenticated Iroh UDP ports may fill omitted ports; no relay, LAN, or discovered candidate may widen the allowlist. | | Generic LAN/custom-VPN raw TCP authorization | Unsupported | Plaintext transport cannot prove the intended Mac or safely carry a Stack bearer. | | Relay-only peer-IP concealment | Unsupported | An admitted peer can receive private candidates; managed relays still observe metadata. | diff --git a/docs/irx-ci-test-plan.md b/docs/irx-ci-test-plan.md index b21a2c9b8c95..e33f87421f5c 100644 --- a/docs/irx-ci-test-plan.md +++ b/docs/irx-ci-test-plan.md @@ -100,7 +100,9 @@ channel. Field-found items name the incident that motivated them. ## Not yet covered / future 29. Direct-path upgrade (post-admission NAT traversal authorization + LAN - hints) once wired: path migration without session drop. + hints) and explicit numeric Direct/Tailscale-only allowlists: prove path + migration without session drop and fail-closed behavior for empty or + unusable pinned candidates. 30. Radio lifecycle on physical devices: lock/unlock, background/foreground, LTE<->WiFi handoff -> recovery <= 3s, every transition attributed. 31. Simulator-stream lanes over irx (currently unsupported). From e3cf4771e7f63229fdab35b9dfc0f9090a11f39b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:20:02 -0700 Subject: [PATCH 08/18] fix: invalidate cached IRX routes on network change --- .../MobileIrxRuntimeComposition.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index a4565ad5e2de..300005187498 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -215,6 +215,7 @@ public actor MobileIrxRuntimeComposition { reachability: reachability, onPathChange: { await lanPeerDiscovery.pathDidChange() + await self.invalidateCachedDirectRoutesForNetworkChange() } ) } @@ -916,6 +917,24 @@ public actor MobileIrxRuntimeComposition { return (binding, discovery) } + /// A network generation change invalidates every cached direct coordinate. + /// The next automatic dial rebuilds public candidates from authenticated + /// broker discovery and LAN candidates from the new Bonjour generation. + /// Clearing the combined cache is deliberate: retaining a public hint is + /// safe but would make it possible for a stale private coordinate to be + /// retried when broker discovery is temporarily unavailable. + private func invalidateCachedDirectRoutesForNetworkChange() { + guard !routesByPeer.isEmpty else { return } + for peerHex in routesByPeer.keys { + guard let route = routesByPeer[peerHex] else { continue } + routesByPeer[peerHex] = (route.relayURL, []) + } + Self.journal.record( + "client-runtime", "direct-routes-invalidated", + ["reason": "network-path-change"] + ) + } + private func engine(forPeer peerHex: String) -> IrxPeerEngine { if let existing = enginesByPeer[peerHex] { return existing } let engine = IrxPeerEngine( From 3b7405584fca3502a066b372b5e70ab6ad95984e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:27:07 -0700 Subject: [PATCH 09/18] fix: stop IRX network observation on sign out --- docs/iroh-app-transport-architecture.md | 16 +++++++++++----- docs/irx-ci-test-plan.md | 9 ++++++--- .../MobileIrohNetworkPathState.swift | 8 ++++++++ .../MobileIrxRuntimeComposition.swift | 19 +++++++++++++++++-- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/iroh-app-transport-architecture.md b/docs/iroh-app-transport-architecture.md index 67844d714c30..785b6d0e11f7 100644 --- a/docs/iroh-app-transport-architecture.md +++ b/docs/iroh-app-transport-architecture.md @@ -18,10 +18,15 @@ Each process owns one Iroh endpoint. A peer route contains one canonical 64-char Production endpoints start from Iroh's `Minimal` preset and add only relays from a verified server policy. They do not use the default n0 preset or public n0 DNS address lookup. The app pins bounded Ed25519 policy keys, while the signed catalog carries relay IDs, providers, regions, and URLs. Fleet changes therefore do not require an app update. The authenticated cmux device registry is the application-specific address lookup: an endpoint publishes the signed public-disclosure subset of its current `watch_addr` value, and same-account peers resolve a known EndpointID through that registry. Private candidates stay out of the broker and are exchanged in-band only after admission. This distinction is required because an EndpointID authenticates a peer but does not say where that peer is reachable. -cmux-supplied addresses have two explicit phases: - -1. Try globally routable direct addresses and the managed relay fleet. After cmux admits the peer, Iroh may exchange its own NAT-traversal candidates and migrate this connection. -2. If bootstrap fails, try an authenticated Bonjour LAN hint for the exact known Mac. A user-selected Direct or Tailscale-only Iroh route instead uses only its stored numeric allowlist, with the current authenticated Iroh UDP port joined at dial time when a candidate omits its port. An empty or invalid allowlist fails closed; no relay or LAN candidate is substituted. +IRX builds one automatic `EndpointAddr` from the authenticated home relay and +validated public direct hints. If authenticated Bonjour finds the exact known +Mac before dialing, its on-link numeric LAN hints are appended to that same +address. After admission, IRX authorizes Iroh NAT traversal so Iroh may learn +additional LAN, WAN, or VPN candidates and migrate the existing connection. +A user-selected Direct or Tailscale-only Iroh route instead uses only its +stored numeric allowlist, with the current authenticated Iroh UDP port joined +at dial time when a candidate omits its port. An empty or invalid allowlist +fails closed; no relay, Bonjour, or discovered candidate is substituted. Private hints never enter the first cmux-supplied `EndpointAddr`. Iroh treats supplied IP paths as equivalent candidates, so array order is not a fallback boundary. @@ -29,7 +34,7 @@ This phase split is not a relay-only IP-privacy boundary. Stock Iroh 1.0 registe After activation, the admitted peer may learn LAN, Tailscale, or other interface addresses even when cmux supplied only a relay URL. cmux documents this behavior and does not claim peer-IP concealment from an admitted peer. Iroh 1.0 still has no relay-only connection mode. Managed deployments that require peer-IP concealment must disable Iroh until a separately tested relay-only mode exists. -For admitted online sessions, this in-band candidate exchange is the generic private-network integration: Iroh can discover a working LAN or VPN interface without cmux publishing private addresses through the broker or identifying a VPN vendor. It cannot help when relay and public-direct bootstrap both fail. The explicit Direct/Tailscale-only allowlist is the relayless or offline escape hatch, and it carries no bearer authorization beyond Iroh TLS and cmux admission. Tailscale raw TCP remains a released-client fallback, not the model for every private network. +For admitted online sessions, this in-band candidate exchange is the generic private-network integration: Iroh can discover a working LAN or VPN interface without cmux publishing private addresses through the broker or identifying a VPN vendor. It cannot help when relay and public-direct bootstrap both fail. The explicit Direct/Tailscale-only allowlist is the relayless or offline escape hatch, and it carries no bearer authorization beyond Iroh TLS and cmux admission. The separately labelled **Private Addresses** settings currently belong to the legacy Iroh composition and are not consumed by IRX. Tailscale raw TCP remains a released-client fallback, not the model for every private network. Path migration may move an established connection between relay and direct reachability without reopening application streams. cmux treats this as one connection and does not assume Iroh stripes bandwidth across paths. @@ -68,6 +73,7 @@ Offline LAN discovery is opt-in. The iOS target must declare its cmux Bonjour se | Managed relay or public-direct Iroh | Supported, default | Admitted control, one server-event owner, and bounded terminal lanes; artifact lanes remain gated. | | Iroh-discovered LAN, Tailscale, or VPN candidate | Supported after admission | Same connection may migrate direct; selection is opportunistic, not guaranteed. | | Authenticated Bonjour LAN Iroh bootstrap | Supported | Exact known EndpointID or one-use offline proof; numeric on-link addresses only. | +| Legacy Private Addresses settings under IRX | Deferred | The settings store exists, but IRX currently uses authenticated Bonjour and native Iroh candidates instead. | | Numeric Tailscale TCP | Compatibility only | Current framed RPC after interface-bound route proof; no Iroh-only features. | | Explicit relayless Tailscale/custom-VPN Iroh hint | Supported when user-pinned | Numeric candidates only; current authenticated Iroh UDP ports may fill omitted ports; no relay, LAN, or discovered candidate may widen the allowlist. | | Generic LAN/custom-VPN raw TCP authorization | Unsupported | Plaintext transport cannot prove the intended Mac or safely carry a Stack bearer. | diff --git a/docs/irx-ci-test-plan.md b/docs/irx-ci-test-plan.md index e33f87421f5c..0fc0851716c3 100644 --- a/docs/irx-ci-test-plan.md +++ b/docs/irx-ci-test-plan.md @@ -103,8 +103,11 @@ channel. Field-found items name the incident that motivated them. hints) and explicit numeric Direct/Tailscale-only allowlists: prove path migration without session drop and fail-closed behavior for empty or unusable pinned candidates. -30. Radio lifecycle on physical devices: lock/unlock, background/foreground, +30. IRX consumption of the existing per-Computer **Private Addresses** store: + either wire its provider-bound profiles into the IRX dial plan or hide the + legacy settings while IRX is enabled. +31. Radio lifecycle on physical devices: lock/unlock, background/foreground, LTE<->WiFi handoff -> recovery <= 3s, every transition attributed. -31. Simulator-stream lanes over irx (currently unsupported). -32. PostHog kill-switch bridge: flag flip reverts to legacy stack on next +32. Simulator-stream lanes over irx (currently unsupported). +33. PostHog kill-switch bridge: flag flip reverts to legacy stack on next launch without wiping state. diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohNetworkPathState.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohNetworkPathState.swift index 8ddc220ab746..a35e78880dff 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohNetworkPathState.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohNetworkPathState.swift @@ -30,6 +30,14 @@ actor MobileIrohNetworkPathState { } } + /// Stops path observation when the owning runtime signs out. Without an + /// explicit stop, a reachability stream can outlive the endpoint identity + /// it was observing and repopulate LAN authorization for the next account. + func stop() { + observationTask?.cancel() + observationTask = nil + } + func snapshot() -> CmxIrohNetworkPathSnapshot { var profiles = Set(lanProfiles.keys) if TailscaleStatus( diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 300005187498..00d614612bc4 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -109,6 +109,7 @@ public actor MobileIrxRuntimeComposition { private var deviceListStore: IrxDeviceListStore? private var provisioningTask: Task? private var provisionInFlight: Task? + private var networkPathObservationTask: Task? /// Auth observation stays alive for the lifetime of the composition. A /// successful first provision must not terminate it, otherwise an /// implicit token clear or account switch leaves the endpoint running. @@ -207,10 +208,18 @@ public actor MobileIrxRuntimeComposition { self.auth = auth legacyComposition = legacy self.controlPlaneBaseURL = controlPlaneBaseURL + networkPathObservationTask?.cancel() + networkPathObservationTask = nil + lifecycleEpoch &+= 1 + let configurationEpoch = lifecycleEpoch if let reachability { let networkPathState = self.networkPathState let lanPeerDiscovery = self.lanPeerDiscovery - Task { + networkPathObservationTask = Task { [weak self] in + guard let self, + await self.isLifecycleEpochCurrent(configurationEpoch), + !Task.isCancelled + else { return } await networkPathState.start( reachability: reachability, onPathChange: { @@ -232,7 +241,6 @@ public actor MobileIrxRuntimeComposition { authObservationTask?.cancel() provisioningTask?.cancel() provisioningTask = nil - lifecycleEpoch &+= 1 // Proactive provisioning is event-driven on auth. The observation task // never exits after a successful provision, so implicit sign-out and // account switches receive the same teardown as explicit sign-out. @@ -274,6 +282,10 @@ public actor MobileIrxRuntimeComposition { epoch == lifecycleEpoch && activeAccountID != nil } + private func isLifecycleEpochCurrent(_ epoch: UInt64) -> Bool { + epoch == lifecycleEpoch + } + private func requireCurrent(_ epoch: UInt64) throws { guard isCurrent(epoch) else { throw CancellationError() } } @@ -484,6 +496,9 @@ public actor MobileIrxRuntimeComposition { /// the next account starts from its own directory. public func handleSignOut() async { lifecycleEpoch &+= 1 + networkPathObservationTask?.cancel() + networkPathObservationTask = nil + await networkPathState.stop() activeAccountID = nil provisioningTask?.cancel() provisioningTask = nil From 664cdcfb47408f6587f011d486b8a1df5cb8553c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:31:32 -0700 Subject: [PATCH 10/18] fix: close queued IRX path observer starts --- docs/iroh-app-transport-architecture.md | 2 +- .../Sources/cmuxFeature/MobileIrohNetworkPathState.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/iroh-app-transport-architecture.md b/docs/iroh-app-transport-architecture.md index 785b6d0e11f7..f80e190e89f7 100644 --- a/docs/iroh-app-transport-architecture.md +++ b/docs/iroh-app-transport-architecture.md @@ -28,7 +28,7 @@ stored numeric allowlist, with the current authenticated Iroh UDP port joined at dial time when a candidate omits its port. An empty or invalid allowlist fails closed; no relay, Bonjour, or discovered candidate is substituted. -Private hints never enter the first cmux-supplied `EndpointAddr`. Iroh treats supplied IP paths as equivalent candidates, so array order is not a fallback boundary. +Broker-private hints never enter the first cmux-supplied `EndpointAddr`; authenticated Bonjour LAN hints are the deliberate, identity-bound exception described above. Iroh treats supplied IP paths as equivalent candidates, so array order is not a fallback boundary. This phase split is not a relay-only IP-privacy boundary. Stock Iroh 1.0 registers a TLS-complete connection with its path manager before cmux can verify a same-account grant, then exchanges public addresses, ports, and local interface addresses. EndpointID TLS proves key possession, not cmux authorization. The pinned cmux noq, Iroh, and FFI forks therefore negotiate QUIC NAT traversal but defer candidate announcement, inbound `REACH_OUT` processing, probes, timers, and direct-path migration on each connection. Every cmux endpoint advertises zero initial bidirectional and unidirectional stream credit; the Mac raises bidirectional credit to one only for the bootstrap control stream. Admission uses an acknowledged two-phase barrier: the Mac verifies the grant and returns an accepted-pending-NAT response, the phone authorizes its exact connection and sends client-ready over the bootstrap path, then the Mac authorizes its exact connection and returns server-ready. Only that final confirmation lets the phone return a connected session. The client separately grants one unidirectional stream to the sole server-event receiver only after that receiver is prepared. Production grants bounded client application-lane credit only after server-ready. One central Mac router owns acceptance, routes terminal lanes to the terminal byte owner, and rejects artifact lanes until a concrete preview consumer registers. Every lane header has a five-second deadline. The Mac's fresh candidate advertisement reaches an already-authorized phone and starts direct-path migration on that same connection. A denial, missing acknowledgement, role-invalid frame, or authorization failure closes the connection without creating a replacement connection. Default upstream behavior remains unchanged unless these endpoint options are enabled. diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohNetworkPathState.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohNetworkPathState.swift index a35e78880dff..a4d45e9cfb7a 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohNetworkPathState.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohNetworkPathState.swift @@ -20,6 +20,10 @@ actor MobileIrohNetworkPathState { reachability: any ReachabilityProviding, onPathChange: @escaping @Sendable () async -> Void = {} ) { + // The owning composition cancels this task during sign-out. Check the + // caller's cancellation state inside the actor as well, so a queued + // start cannot run after `stop()` won the actor ordering. + guard !Task.isCancelled else { return } observationTask?.cancel() observationTask = Task { [weak self] in for await _ in reachability.pathChanges() { From 976b38d444e66f4ad6d72859e7ce5fc5f87664dc Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:36:51 -0700 Subject: [PATCH 11/18] fix: fence IRX routes against network changes --- .../MobileIrxRuntimeComposition.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 00d614612bc4..5b07b416b4c5 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -43,6 +43,7 @@ public actor MobileIrxRuntimeComposition { case unsupportedRoute case peerNotDiscovered case directDialUnavailable + case networkPathChanged } /// Dial-gate refusals from the device-list lease. Deliberately NOT @@ -222,9 +223,9 @@ public actor MobileIrxRuntimeComposition { else { return } await networkPathState.start( reachability: reachability, - onPathChange: { + onPathChange: { [weak self] in await lanPeerDiscovery.pathDidChange() - await self.invalidateCachedDirectRoutesForNetworkChange() + await self?.invalidateCachedDirectRoutesForNetworkChange() } ) } @@ -927,7 +928,7 @@ public actor MobileIrxRuntimeComposition { let existing = routesByPeer[peerHex] routesByPeer[peerHex] = ( relay ?? existing?.relayURL, - direct.isEmpty ? (existing?.directAddresses ?? []) : direct + direct ) return (binding, discovery) } @@ -1002,6 +1003,7 @@ public actor MobileIrxRuntimeComposition { try enforceDialGate(peerHex: peerHex) let credentials = try await autopilot.usableCredentials() let dialIntent = dialIntentByPeer[peerHex] ?? .automatic + let dialNetworkGeneration = await networkPathState.snapshot().generation var relayURL: String? var directAddresses: [String] = [] let discoveredRoute: (binding: CmxIrohBrokerBinding, discovery: CmxIrohDiscoveryResponse)? @@ -1084,6 +1086,16 @@ public actor MobileIrxRuntimeComposition { ["peer": String(peerHex.prefix(12)), "relay": relayURL ?? "-"] ) } + if case .automatic = dialIntent, + await networkPathState.snapshot().generation != dialNetworkGeneration + { + Self.journal.record( + "client-dial", "network-path-changed-before-dial", + ["peer": String(peerHex.prefix(12))] + ) + invalidateCachedDirectRoutesForNetworkChange() + throw CompositionError.networkPathChanged + } let address = try supervisor.dialAddress( peerEndpointIDHex: peerHex, relayURL: relayURL, From 04bb71b6cdbcf23703eaf508eb832612f7a43bdb Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:40:41 -0700 Subject: [PATCH 12/18] fix: clear retired IRX relay hints --- .../Sources/cmuxFeature/MobileIrxRuntimeComposition.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 5b07b416b4c5..ae095c960041 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -925,9 +925,8 @@ public actor MobileIrxRuntimeComposition { let direct = binding.pathHints.filter { $0.kind == .directAddress && $0.isUsable(at: now) }.map(\.value) - let existing = routesByPeer[peerHex] routesByPeer[peerHex] = ( - relay ?? existing?.relayURL, + relay, direct ) return (binding, discovery) From cf929337aaf3dc4a21b8517744f225503551ec33 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:50:18 -0700 Subject: [PATCH 13/18] fix: invalidate routes before advancing LAN generation --- .../Sources/cmuxFeature/MobileIrxRuntimeComposition.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index ae095c960041..80f20d2ac592 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -224,8 +224,8 @@ public actor MobileIrxRuntimeComposition { await networkPathState.start( reachability: reachability, onPathChange: { [weak self] in - await lanPeerDiscovery.pathDidChange() await self?.invalidateCachedDirectRoutesForNetworkChange() + await lanPeerDiscovery.pathDidChange() } ) } From 0ab6c71809ab3277b60f8d2ac352515d1ffed435 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:24:03 -0700 Subject: [PATCH 14/18] test: cover IRX private address consumption --- .../CmxIrohCustomPrivatePathStoreTests.swift | 24 +++++++++++++++++++ .../IrxBrokerArmingTests.swift | 10 ++++++++ 2 files changed, 34 insertions(+) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathStoreTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathStoreTests.swift index 03ecf31f2b48..6eeb20e34fec 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathStoreTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathStoreTests.swift @@ -171,6 +171,30 @@ struct CmxIrohCustomPrivatePathStoreTests { ).isEmpty) } + @Test + func enabledPathsJoinOnlyToMatchingPublishedFamilyPorts() throws { + let profile = try CmxIrohNetworkProfileKey( + source: .customVPN, + profileID: opaqueProfileID("private-path") + ) + let paths = try ["10.0.0.8", "fd00::8", "10.0.0.8"].map { + try CmxIrohCustomPrivatePathBootstrap( + address: CmxIrohCustomPrivateAddress($0), + networkProfile: profile + ) + } + let ipv4Only = try CmxIrohDirectPorts(ipv4: 49152) + + #expect(CmxIrohCustomPrivatePathBootstrap.dialAddresses( + paths, + directPorts: ipv4Only + ) == ["10.0.0.8:49152"]) + #expect(CmxIrohCustomPrivatePathBootstrap.dialAddresses( + paths, + directPorts: try CmxIrohDirectPorts(ipv4: 49152, ipv6: 49153) + ) == ["10.0.0.8:49152", "[fd00::8]:49153"]) + } + @Test func composerChangesGenerationWhenEitherAuthorityChanges() async throws { let platformProfile = try CmxIrohNetworkProfileKey( diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxBrokerArmingTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxBrokerArmingTests.swift index b75e5117be9e..4490f6c18a65 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxBrokerArmingTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxBrokerArmingTests.swift @@ -65,6 +65,16 @@ enum IrxBrokerArmingSupport { @Suite("broker signing arming") struct IrxBrokerArmingTests { + @Test("Mac registrations advertise custom private-path support") + func registrationCapabilitiesDescribePlatformSupport() { + #expect(IrxBrokerService.registrationCapabilities(for: .mac).contains( + "iroh.private_paths.v1" + )) + #expect(!IrxBrokerService.registrationCapabilities(for: .ios).contains( + "iroh.private_paths.v1" + )) + } + @Test("a cached binding arms request signing at init, before any register()") func cachedBindingArmsSigning() async throws { let identity = IrxBrokerArmingSupport.identity() From 41f87ab034ed9f829b79732d6debf9fd1a02cd1d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:38:52 -0700 Subject: [PATCH 15/18] Wire Private Addresses into IRX --- .../CmxIrohCustomPrivatePathStore.swift | 20 ++ .../CmuxIrxTransport/IrxBrokerService.swift | 11 +- .../MacComputerDetailView.swift | 7 +- Sources/Mobile/MobileHostIrxRuntime.swift | 6 +- ios/cmux/AppCompositionRoot.swift | 12 ++ ios/cmux/cmuxApp.swift | 2 +- .../MobileIrxRuntimeComposition.swift | 185 +++++++++++++++++- .../MobileIrxSettingsController.swift | 142 ++++++++++++++ 8 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 ios/cmuxPackage/Sources/cmuxFeature/MobileIrxSettingsController.swift diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomPrivatePathStore.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomPrivatePathStore.swift index 1c70d55227c5..c8c38c83f77e 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomPrivatePathStore.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomPrivatePathStore.swift @@ -108,6 +108,26 @@ public struct CmxIrohCustomPrivatePathBootstrap: Equatable, Sendable { self.address = address self.networkProfile = networkProfile } + + /// Joins user-configured IPs to the authenticated Mac's current Iroh UDP + /// ports. Missing address families fail closed, and duplicate coordinates + /// are removed before they reach the Iroh connection pool. + public static func dialAddresses( + _ paths: [CmxIrohCustomPrivatePathBootstrap], + directPorts: CmxIrohDirectPorts? + ) -> [String] { + var seen = Set() + return paths.compactMap { path in + let port = switch path.address.family { + case .ipv4: directPorts?.ipv4 + case .ipv6: directPorts?.ipv6 + } + guard let port, port != 0 else { return nil } + let value = path.address.socketAddress(port: port) + guard seen.insert(value).inserted else { return nil } + return value + } + } } /// Device-only, account-isolated persistence for explicit private addresses. diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift index 81054855ac6f..a4f4bff5732d 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxBrokerService.swift @@ -81,6 +81,15 @@ public struct IrxGrantSnapshot: Codable, Equatable, Sendable { /// plumbing) under irx's temporal rules: every result is cached to disk, the /// dial path never waits on the backend, and every call is journaled. public actor IrxBrokerService { + static func registrationCapabilities(for platform: CmxIrohPlatform) -> [String] { + switch platform { + case .mac: + ["cmux.irx.v1", "iroh.private_paths.v1"] + case .ios: + ["cmux.irx.v1"] + } + } + public struct Configuration: Sendable { public var baseURL: URL public var clientNamespace: String @@ -342,7 +351,7 @@ public actor IrxBrokerService { endpointID: identity.endpointIDHex, identityGeneration: configuration.identityGeneration, pairingEnabled: pairingEnabled, - capabilities: ["cmux.irx.v1"], + capabilities: Self.registrationCapabilities(for: configuration.platform), pathHints: hints, directPorts: directPorts ) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift index 4e2fa681a97a..459470b735f5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift @@ -390,7 +390,12 @@ struct MacComputerDetailView: View { )] } if let registryEntry = thisMacPrivateNetworkRegistryEntry { - return [registryEntry] + return [.init( + macDeviceID: registryEntry.macDeviceID, + instanceTag: registryEntry.instanceTag, + displayName: displayTitle, + supportsPrivatePaths: registryEntry.supportsPrivatePaths + )] } return [] } diff --git a/Sources/Mobile/MobileHostIrxRuntime.swift b/Sources/Mobile/MobileHostIrxRuntime.swift index 16067f203a4f..fe9e8c406891 100644 --- a/Sources/Mobile/MobileHostIrxRuntime.swift +++ b/Sources/Mobile/MobileHostIrxRuntime.swift @@ -299,7 +299,11 @@ final class MobileHostIrxRuntime { appVersion: IrxCtlClientInfo.appVersionString( infoDictionary: Bundle.main.infoDictionary), releaseTrack: Self.hostReleaseTrack(), - capabilities: ["cmux.irx.v2", "list-auth"] + capabilities: [ + "cmux.irx.v2", + "list-auth", + "iroh.private_paths.v1", + ] ), clientNamespace: namespace.rawValue ), diff --git a/ios/cmux/AppCompositionRoot.swift b/ios/cmux/AppCompositionRoot.swift index 36e901dfa94b..c7bd8d1c5972 100644 --- a/ios/cmux/AppCompositionRoot.swift +++ b/ios/cmux/AppCompositionRoot.swift @@ -25,6 +25,10 @@ final class AppCompositionRoot { /// The irx (from-scratch iroh) composition when its DEBUG flag owns the /// `.iroh` route; nil when the legacy runtime is active. let irx: MobileIrxRuntimeComposition? + /// Settings surface routed to the same Iroh implementation that owns + /// connections. In IRX mode, private addresses must never mutate only the + /// dormant legacy runtime. + let irohSettingsController: any CmxIrohSettingsControlling /// irx-backed first-pair discovery/forget; nil when legacy owns the slot. let irxDiscovery: MobileIrxDiscoveryProvider? /// One build-compatibility policy shared by discovery, persistence, and @@ -104,6 +108,14 @@ final class AppCompositionRoot { self.auth = auth self.iroh = iroh self.irx = irx + if let irx { + self.irohSettingsController = MobileIrxSettingsController( + irx: irx, + legacy: iroh + ) + } else { + self.irohSettingsController = iroh + } self.irxDiscovery = irxDiscovery self.buildCompatibilityPolicy = buildCompatibilityPolicy self.reachability = reachability diff --git a/ios/cmux/cmuxApp.swift b/ios/cmux/cmuxApp.swift index d4eb8063b333..eb007a9d054c 100644 --- a/ios/cmux/cmuxApp.swift +++ b/ios/cmux/cmuxApp.swift @@ -218,7 +218,7 @@ struct cmuxApp: App { mobileRootScene #endif } - .environment(\.irohSettingsController, Self.root.iroh) + .environment(\.irohSettingsController, Self.root.irohSettingsController) .environment(\.mobileKeyboardFrameTracker, Self.root.keyboardFrameTracker) .environment( \.dogfoodAttachPreparation, diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 80f20d2ac592..286d3a5e42c3 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -87,6 +87,10 @@ public actor MobileIrxRuntimeComposition { /// authenticated Bonjour LAN profiles on the same network generations. private let networkPathState: MobileIrohNetworkPathState private let lanPeerDiscovery: CmxIrohLANPeerDiscovery + /// Device-local, account-scoped user overrides from the Private Addresses + /// editor. IRX reads the same persisted store as the retired runtime, so + /// switching transport implementations never loses the user's routes. + private let customPrivatePaths: CmxIrohCustomPrivatePathStore private let reachability: (any ReachabilityProviding)? private weak var auth: AuthCoordinator? @@ -138,6 +142,8 @@ public actor MobileIrxRuntimeComposition { private var claimedControlSessions: [String: UUID] = [:] /// The events uni-lane accept is single-consumer per session too. private var claimedEventSessions: Set = [] + /// Change-only stream consumed by the MainActor settings adapter. + private var settingsContinuations: [UUID: AsyncStream.Continuation] = [:] @MainActor public init( @@ -169,8 +175,10 @@ public actor MobileIrxRuntimeComposition { self.keychainAccessGroup = keychainAccessGroup self.networkPathState = networkPathState self.lanPeerDiscovery = lanPeerDiscovery + self.customPrivatePaths = CmxIrohCustomPrivatePathStore( + store: CmxIrohUserDefaultsInstallStateStore(defaults: defaults) + ) self.reachability = reachability - _ = defaults let appNamespace = injectedAppNamespace ?? MobileIOSAppNamespace(bundleIdentifier: bundleIdentifier) clientNamespace = appNamespace?.bundleIdentifier ?? "legacy" @@ -481,6 +489,7 @@ public actor MobileIrxRuntimeComposition { minimumSupportedMacVersion: snapshot.minimumSupportedMacVersion ) } + publishSettingsUpdate() } /// UI/programmatic lookup: the peer's list-auth stance right now. @@ -552,6 +561,7 @@ public actor MobileIrxRuntimeComposition { await MainActor.run { MobileMacListAuthState.shared.clear() } + publishSettingsUpdate() Self.journal.record("client-runtime", "device-list-signed-out") } @@ -838,6 +848,151 @@ public actor MobileIrxRuntimeComposition { return try? await auth.authenticatedSessionSnapshot().accountID } + // MARK: - Private Addresses settings + + /// Adds the active IRX device directory and device-local private-address + /// settings to the existing settings snapshot consumed by SwiftUI. + public func settingsSnapshot( + overlaying base: CmxIrohSettingsSnapshot + ) async -> CmxIrohSettingsSnapshot { + let privateSnapshot = if let activeAccountID { + await customPrivatePaths.availableSnapshot(accountID: activeAccountID) + } else { + CmxIrohCustomPrivatePathSnapshot.unavailable + } + var macsByID: [String: CmxIrohSettingsSnapshot.PrivateNetworkMac] = [:] + if let directory = deviceListBox.current { + for entry in directory.entries.values { + guard let deviceID = entry.deviceID else { continue } + let identity = CmxMacAppInstanceIdentity( + macDeviceID: deviceID, + instanceTag: entry.tag + ) + let supportsPrivatePaths = entry.capabilities?.contains( + "iroh.private_paths.v1" + ) == true + macsByID[identity.id] = .init( + macDeviceID: identity.macDeviceID, + instanceTag: identity.instanceTag, + displayName: identity.macDeviceID, + supportsPrivatePaths: supportsPrivatePaths + ) + } + } + for configuration in privateSnapshot.configurations + where macsByID[configuration.id] == nil { + macsByID[configuration.id] = .init( + macDeviceID: configuration.macDeviceID, + instanceTag: configuration.instanceTag, + displayName: configuration.macDisplayName + ) + } + return CmxIrohSettingsSnapshot( + runtimeStatus: base.runtimeStatus, + selectedTransportPath: base.selectedTransportPath, + preference: base.preference, + pathPreference: Self.forceRelayOnly ? .relayOnly : .automatic, + managedRelays: base.managedRelays, + customRelays: base.customRelays, + privateNetworkMacs: macsByID.values.sorted { $0.id < $1.id }, + customPrivateNetworks: privateSnapshot.configurations.map { + .init( + macDeviceID: $0.macDeviceID, + instanceTag: $0.instanceTag, + macDisplayName: $0.macDisplayName, + addresses: $0.addresses.map(\.value), + isEnabled: $0.isEnabled + ) + }, + policySource: base.policySource, + policySequence: base.policySequence, + policyExpiresAt: base.policyExpiresAt, + staleRelayIDs: base.staleRelayIDs, + failureDescription: base.failureDescription, + debugTransportVerificationMode: base.debugTransportVerificationMode + ) + } + + public func settingsUpdates() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + settingsContinuations[id] = continuation + continuation.onTermination = { @Sendable [weak self] _ in + Task { await self?.removeSettingsContinuation(id) } + } + return stream + } + + public func refreshSettingsSnapshot() { + publishSettingsUpdate() + } + + public func upsertCustomPrivatePath( + _ path: CmxIrohCustomPrivatePathDraft + ) async throws { + guard let activeAccountID else { throw CompositionError.notSignedIn } + _ = try await customPrivatePaths.upsert(path, accountID: activeAccountID) + await privatePathSettingsChanged( + macDeviceID: path.macDeviceID, + instanceTag: path.instanceTag + ) + } + + public func removeCustomPrivatePath( + macDeviceID: String, + instanceTag: String? + ) async throws { + guard let activeAccountID else { throw CompositionError.notSignedIn } + _ = try await customPrivatePaths.remove( + macDeviceID: macDeviceID, + instanceTag: instanceTag, + accountID: activeAccountID + ) + await privatePathSettingsChanged( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) + } + + private func privatePathSettingsChanged( + macDeviceID: String, + instanceTag: String? + ) async { + let requested = CmxMacAppInstanceIdentity( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) + let peers = deviceListBox.current?.entries.compactMap { peerHex, entry in + guard let deviceID = entry.deviceID else { return nil } + let candidate = CmxMacAppInstanceIdentity( + macDeviceID: deviceID, + instanceTag: entry.tag + ) + return candidate.id == requested.id ? peerHex : nil + } ?? [] + for peerHex in peers { + if let route = routesByPeer[peerHex] { + routesByPeer[peerHex] = (route.relayURL, []) + } + await enginesByPeer[peerHex]?.relayHintChanged( + trigger: "private-path-change" + ) + } + publishSettingsUpdate() + } + + private func removeSettingsContinuation(_ id: UUID) { + settingsContinuations.removeValue(forKey: id) + } + + private func publishSettingsUpdate() { + for continuation in settingsContinuations.values { + continuation.yield() + } + } + // MARK: - Dialing private func peerTarget(for request: CmxByteTransportRequest) throws -> String { @@ -1065,6 +1220,34 @@ public actor MobileIrxRuntimeComposition { ) } } + if !Self.forceRelayOnly, + case .automatic = dialIntent, + let discoveredRoute, + let activeAccountID + { + let configured = await customPrivatePaths.enabledPaths( + forMacDeviceID: discoveredRoute.binding.deviceID, + instanceTag: discoveredRoute.binding.tag, + accountID: activeAccountID + ) + let privateAddresses = CmxIrohCustomPrivatePathBootstrap.dialAddresses( + configured, + directPorts: discoveredRoute.binding.directPorts + ) + if !privateAddresses.isEmpty { + for address in privateAddresses where !directAddresses.contains(address) { + directAddresses.append(address) + } + routesByPeer[peerHex] = (relayURL, directAddresses) + Self.journal.record( + "client-dial", "private-hints-adopted", + [ + "peer": String(peerHex.prefix(12)), + "count": String(privateAddresses.count), + ] + ) + } + } Self.journal.record( "client-dial", "target-resolved", [ diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxSettingsController.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxSettingsController.swift new file mode 100644 index 000000000000..b72e6a71eaaf --- /dev/null +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxSettingsController.swift @@ -0,0 +1,142 @@ +public import CMUXMobileCore +public import Foundation + +/// MainActor adapter for the shared SwiftUI networking surface while IRX owns +/// the transport. Legacy remains the source for diagnostics until those move, +/// while private-address reads and mutations go to the active IRX runtime. +@MainActor +public final class MobileIrxSettingsController: CmxIrohSettingsControlling { + private let irx: MobileIrxRuntimeComposition + private let legacy: any CmxIrohSettingsControlling + + public init( + irx: MobileIrxRuntimeComposition, + legacy: any CmxIrohSettingsControlling + ) { + self.irx = irx + self.legacy = legacy + } + + public func irohSettingsSnapshot() async -> CmxIrohSettingsSnapshot { + let base = await legacy.irohSettingsSnapshot() + return await irx.settingsSnapshot(overlaying: base) + } + + public func irohSettingsUpdates() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + let irxTask = Task { @MainActor [weak self] in + guard let self else { return } + let changes = await irx.settingsUpdates() + for await _ in changes { + guard !Task.isCancelled else { return } + continuation.yield(await irohSettingsSnapshot()) + } + } + let legacyTask = Task { @MainActor [weak self] in + guard let self else { return } + for await _ in legacy.irohSettingsUpdates() { + guard !Task.isCancelled else { return } + continuation.yield(await irohSettingsSnapshot()) + } + } + continuation.onTermination = { @Sendable _ in + irxTask.cancel() + legacyTask.cancel() + } + return stream + } + + public func setIrohRelayPreference( + _ preference: CmxIrohRelayPreferenceDraft + ) async throws { + guard case .automatic = preference else { + throw CmxIrohSettingsControlError.unsupported + } + } + + public func setIrohPathPreference( + _ preference: CmxIrohPathPreference + ) async throws { + let active: CmxIrohPathPreference = MobileIrxRuntimeComposition.forceRelayOnly + ? .relayOnly : .automatic + guard preference == active else { + throw CmxIrohSettingsControlError.unsupported + } + } + + public func upsertIrohCustomRelay( + _ relay: CmxIrohCustomRelayDraft, + deviceSecret: String? + ) async throws { + throw CmxIrohSettingsControlError.unsupported + } + + public func removeIrohCustomRelay(id: String) async throws { + throw CmxIrohSettingsControlError.unsupported + } + + public func testIrohCustomRelay(id: String) async -> CmxIrohRelayTestResult { + .incomplete + } + + public func upsertIrohCustomPrivatePath( + _ path: CmxIrohCustomPrivatePathDraft + ) async throws { + try await irx.upsertCustomPrivatePath(path) + } + + public func removeIrohCustomPrivatePath( + macDeviceID: String, + instanceTag: String? + ) async throws { + try await irx.removeCustomPrivatePath( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) + } + + public func resetIrohSettingsToDefaults() async throws { + let snapshot = await irohSettingsSnapshot() + for path in snapshot.customPrivateNetworks where path.isEnabled { + try await upsertIrohCustomPrivatePath(.init( + macDeviceID: path.macDeviceID, + instanceTag: path.instanceTag, + macDisplayName: path.macDisplayName, + addresses: path.addresses, + isEnabled: false + )) + } + } + + public func refreshIrohSettings() async { + await irx.refreshSettingsSnapshot() + } + + public func runIrohConnectionCheck() async -> CmxIrohConnectionCheckReport { + CmxIrohConnectionCheckReport( + role: .mobileClient, + snapshot: await irohSettingsSnapshot(), + diagnostics: await legacy.irohDiagnosticReport(), + relayReachability: .unavailable, + macDiscovery: .unavailable + ) + } + + public func irohDiagnosticReport() async -> DiagnosticReport { + await legacy.irohDiagnosticReport() + } + + public func exportIrohDiagnosticReport() async -> Data { + await legacy.exportIrohDiagnosticReport() + } + + public func clearIrohDiagnosticReport() async { + await legacy.clearIrohDiagnosticReport() + } + + public func irohPreviousLaunchDiagnosticReport() async -> DiagnosticReport? { + await legacy.irohPreviousLaunchDiagnosticReport() + } +} From b41847bdd44b27e62a8b4d05bbe8b69a64ab724d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:02:36 -0700 Subject: [PATCH 16/18] fix: type device list peer lookup --- .../Sources/cmuxFeature/MobileIrxRuntimeComposition.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 286d3a5e42c3..709bf2d7a63d 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -964,7 +964,9 @@ public actor MobileIrxRuntimeComposition { macDeviceID: macDeviceID, instanceTag: instanceTag ) - let peers = deviceListBox.current?.entries.compactMap { peerHex, entry in + let peers = deviceListBox.current?.entries.compactMap { pair in + let peerHex = pair.key + let entry = pair.value guard let deviceID = entry.deviceID else { return nil } let candidate = CmxMacAppInstanceIdentity( macDeviceID: deviceID, From 70c76db1cac9c7bc43dbff56a08987f3c753470f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:08:50 -0700 Subject: [PATCH 17/18] fix: iterate device list entries explicitly --- .../MobileIrxRuntimeComposition.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 709bf2d7a63d..76fa17e7eb63 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -964,16 +964,19 @@ public actor MobileIrxRuntimeComposition { macDeviceID: macDeviceID, instanceTag: instanceTag ) - let peers = deviceListBox.current?.entries.compactMap { pair in - let peerHex = pair.key - let entry = pair.value - guard let deviceID = entry.deviceID else { return nil } - let candidate = CmxMacAppInstanceIdentity( - macDeviceID: deviceID, - instanceTag: entry.tag - ) - return candidate.id == requested.id ? peerHex : nil - } ?? [] + var peers: [String] = [] + if let snapshot = deviceListBox.current { + for (peerHex, entry) in snapshot.entries { + guard let deviceID = entry.deviceID else { continue } + let candidate = CmxMacAppInstanceIdentity( + macDeviceID: deviceID, + instanceTag: entry.tag + ) + if candidate.id == requested.id { + peers.append(peerHex) + } + } + } for peerHex in peers { if let route = routesByPeer[peerHex] { routesByPeer[peerHex] = (route.relayURL, []) From 9261013525ee4c5afefdb089c0fba53bba732586 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:51:15 -0700 Subject: [PATCH 18/18] fix: bound IRX path merge and own startup task --- .../MobileIrxRuntimeComposition.swift | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 76fa17e7eb63..3e033eef499b 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -114,7 +114,6 @@ public actor MobileIrxRuntimeComposition { private var deviceListStore: IrxDeviceListStore? private var provisioningTask: Task? private var provisionInFlight: Task? - private var networkPathObservationTask: Task? /// Auth observation stays alive for the lifetime of the composition. A /// successful first provision must not terminate it, otherwise an /// implicit token clear or account switch leaves the endpoint running. @@ -213,30 +212,22 @@ public actor MobileIrxRuntimeComposition { auth: AuthCoordinator, legacy: MobileIrohRuntimeComposition? = nil, controlPlaneBaseURL: URL? = nil - ) { + ) async { self.auth = auth legacyComposition = legacy self.controlPlaneBaseURL = controlPlaneBaseURL - networkPathObservationTask?.cancel() - networkPathObservationTask = nil lifecycleEpoch &+= 1 let configurationEpoch = lifecycleEpoch if let reachability { - let networkPathState = self.networkPathState - let lanPeerDiscovery = self.lanPeerDiscovery - networkPathObservationTask = Task { [weak self] in - guard let self, - await self.isLifecycleEpochCurrent(configurationEpoch), - !Task.isCancelled - else { return } - await networkPathState.start( - reachability: reachability, - onPathChange: { [weak self] in - await self?.invalidateCachedDirectRoutesForNetworkChange() - await lanPeerDiscovery.pathDidChange() - } - ) - } + guard isLifecycleEpochCurrent(configurationEpoch), + !Task.isCancelled else { return } + await networkPathState.start( + reachability: reachability, + onPathChange: { [weak self] in + await self?.invalidateCachedDirectRoutesForNetworkChange() + await self?.lanPeerDiscovery.pathDidChange() + } + ) } Self.journal.record( "client-runtime", "configured", @@ -506,8 +497,6 @@ public actor MobileIrxRuntimeComposition { /// the next account starts from its own directory. public func handleSignOut() async { lifecycleEpoch &+= 1 - networkPathObservationTask?.cancel() - networkPathObservationTask = nil await networkPathState.stop() activeAccountID = nil provisioningTask?.cancel() @@ -1208,9 +1197,14 @@ public actor MobileIrxRuntimeComposition { expectedMacDeviceID: expectedDeviceID, expectedEndpointID: discoveredRoute.binding.endpointID ) { - var direct = directAddresses - for peer in peers where peer.binding.endpointID == discoveredRoute.binding.endpointID { - for hint in peer.pathHints where !direct.contains(hint.value) { + var direct = Array(directAddresses.prefix(16)) + var seenDirect = Set(direct) + for peer in peers + where peer.binding.endpointID == discoveredRoute.binding.endpointID + && direct.count < 16 + { + for hint in peer.pathHints where direct.count < 16 { + guard seenDirect.insert(hint.value).inserted else { continue } direct.append(hint.value) } } @@ -1240,7 +1234,9 @@ public actor MobileIrxRuntimeComposition { directPorts: discoveredRoute.binding.directPorts ) if !privateAddresses.isEmpty { - for address in privateAddresses where !directAddresses.contains(address) { + var seenDirect = Set(directAddresses) + for address in privateAddresses where directAddresses.count < 16 { + guard seenDirect.insert(address).inserted else { continue } directAddresses.append(address) } routesByPeer[peerHex] = (relayURL, directAddresses)