Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7782171
Fix cold signed-out Iroh identity cleanup
azooz2003-bit Sep 3, 2026
6f83caf
test: cover disabled Iroh control idle timeout
azooz2003-bit Sep 3, 2026
e20e3ae
fix: keep Iroh host sessions alive when control is idle
azooz2003-bit Sep 3, 2026
0b29eb8
Prevent duplicate Iroh control lane redials
azooz2003-bit Sep 3, 2026
5f83ece
Wire IRX direct and authenticated LAN paths
azooz2003-bit Sep 3, 2026
ca5fff9
Honor explicit IRX direct dial allowlists
azooz2003-bit Sep 3, 2026
7b0f06e
docs: document IRX direct path support
azooz2003-bit Sep 3, 2026
e3cf477
fix: invalidate cached IRX routes on network change
azooz2003-bit Sep 4, 2026
3b74055
fix: stop IRX network observation on sign out
azooz2003-bit Sep 4, 2026
664cdcf
fix: close queued IRX path observer starts
azooz2003-bit Sep 4, 2026
976b38d
fix: fence IRX routes against network changes
azooz2003-bit Sep 4, 2026
04bb71b
fix: clear retired IRX relay hints
azooz2003-bit Sep 4, 2026
cf92933
fix: invalidate routes before advancing LAN generation
azooz2003-bit Sep 4, 2026
0ab6c71
test: cover IRX private address consumption
azooz2003-bit Sep 4, 2026
41f87ab
Wire Private Addresses into IRX
azooz2003-bit Sep 4, 2026
b41847b
fix: type device list peer lookup
azooz2003-bit Sep 4, 2026
70c76db
fix: iterate device list entries explicitly
azooz2003-bit Sep 4, 2026
9261013
fix: bound IRX path merge and own startup task
azooz2003-bit Sep 5, 2026
7e6b0eb
Merge origin/main into feat-ios-parallel-secondary
azooz2003-bit Sep 5, 2026
39a7cd0
Merge remote-tracking branch 'origin/main' into feat-pr11874-mergefix
azooz2003-bit Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UInt16> = []
var ipv6Ports: Set<UInt16> = []
var ipv4WildcardPorts: Set<UInt16> = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,7 +152,12 @@ public actor IrxBrokerService {
private let credentialCache: any IrxJSONCache<IrxRelayCredentialSnapshot>
private let grantCache: any IrxJSONCache<[String: IrxGrantSnapshot]>
private var registrationInFlight: Task<IrxBindingSnapshot, any Error>?
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
Expand Down Expand Up @@ -240,22 +254,33 @@ 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;
/// pathHints advertise the relay URL so peers can dial relay-first.
public func register(
pairingEnabled: Bool,
relayURLHint: String?,
directAddresses: [String] = [],
directPorts: CmxIrohDirectPorts? = nil
) async throws -> IrxBindingSnapshot {
let epoch = try beginOperation()
Expand All @@ -266,6 +291,7 @@ public actor IrxBrokerService {
try await self.registerOnce(
pairingEnabled: pairingEnabled,
relayURLHint: relayURLHint,
directAddresses: directAddresses,
directPorts: directPorts,
epoch: epoch
)
Expand All @@ -278,6 +304,7 @@ public actor IrxBrokerService {
private func registerOnce(
pairingEnabled: Bool,
relayURLHint: String?,
directAddresses: [String],
directPorts: CmxIrohDirectPorts?,
epoch: UInt64
) async throws -> IrxBindingSnapshot {
Expand All @@ -297,6 +324,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)
Expand All @@ -310,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
)
Expand All @@ -331,7 +372,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(
Expand Down Expand Up @@ -705,4 +751,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<String>()
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ public import Foundation
public actor IrxControlByteTransport: CmxByteTransport {
/// The asynchronous factory used to obtain an admitted QUIC connection and control lane.
public typealias Establish = @Sendable () async throws -> (IrxConnection, IrxLaneStream)

/// Called once when this transport releases its control lane.
public typealias OnClose = @Sendable () async -> Void

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,12 @@ struct MacComputerDetailView: View {
)]
}
if let registryEntry = thisMacPrivateNetworkRegistryEntry {
return [registryEntry]
return [.init(
macDeviceID: registryEntry.macDeviceID,
instanceTag: registryEntry.instanceTag,
displayName: displayTitle,
supportsPrivatePaths: registryEntry.supportsPrivatePaths
)]
}
return []
}
Expand Down
5 changes: 3 additions & 2 deletions Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading