Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -0,0 +1,130 @@
import Foundation

private struct CmxIrohPathHintMergeKey: Hashable {
let kind: String
let value: String
let source: String
let networkProfile: CmxIrohNetworkProfileKey?

init(_ hint: CmxIrohPathHint) {
kind = hint.kind.rawValue
value = hint.value
source = hint.source.rawValue
networkProfile = hint.networkProfile
}
}

public extension CmxIrohNetworkProfileKey {
/// The strongest Tailscale profile iOS can prove without a provider API.
///
/// Apple exposes the active packet tunnel and its assigned addresses, but
/// not Tailscale's tailnet identifier. This profile therefore means "a
/// Tailscale tunnel is active on this device." It is routing metadata only;
/// the Iroh EndpointID remains the peer-authentication authority.
static let activeTailscaleTunnel: CmxIrohNetworkProfileKey = {
do {
return try CmxIrohNetworkProfileKey(
source: .tailscale,
profileID: "42e59eea27473bde00430ca3d4a0f34a372713f0b90d46ee1ab2802c6d668979"
)
} catch {
preconditionFailure("The built-in Tailscale network profile is invalid: \(error)")
}
}()
}

public extension CmxAttachRoute {
/// Adds short-lived Tailscale addresses to every existing Iroh route.
///
/// Private paths never contribute identity or authorization. They are
/// attached only to an existing Iroh EndpointID and remain fallback-only,
/// so a wrong or stale address can only fail Iroh's authenticated handshake.
/// Numeric Tailscale validation rejects LAN, public, MagicDNS, service, and
/// generic host routes. The original raw routes remain in the returned set
/// for rolling compatibility, but callers continue pinning connection
/// attempts to Iroh whenever an Iroh route exists.
static func addingIrohPrivatePaths(
to routes: [CmxAttachRoute],
observedAt: Date
) -> [CmxAttachRoute] {
let maximumHintCount = CmxAttachEndpoint.maximumIrohPathHintCount
var candidateKeys: Set<CmxIrohPathHintMergeKey> = []
var candidates: [(key: CmxIrohPathHintMergeKey, hint: CmxIrohPathHint)] = []
candidates.reserveCapacity(maximumHintCount)
for route in routes where candidates.count < maximumHintCount {
guard let hint = route.irohTailscalePathHint(observedAt: observedAt) else {
continue
}
let key = CmxIrohPathHintMergeKey(hint)
guard candidateKeys.insert(key).inserted else { continue }
candidates.append((key, hint))
}
guard !candidates.isEmpty else { return routes }

return routes.map { route in
guard route.kind == .iroh,
case let .peer(identity, pathHints) = route.endpoint else {
return route
}

let usableHints = pathHints.filter { $0.isUsable(at: observedAt) }
var existingCounts: [CmxIrohPathHintMergeKey: Int] = [:]
existingCounts.reserveCapacity(usableHints.count)
for hint in usableHints {
existingCounts[CmxIrohPathHintMergeKey(hint), default: 0] += 1
}

var removedExistingKeys: Set<CmxIrohPathHintMergeKey> = []
var appendedCandidates: [CmxIrohPathHint] = []
appendedCandidates.reserveCapacity(candidates.count)
var mergedCount = usableHints.count
for candidate in candidates {
if let removedCount = existingCounts[candidate.key] {
removedExistingKeys.insert(candidate.key)
mergedCount -= removedCount
}
guard mergedCount < maximumHintCount else { continue }
appendedCandidates.append(candidate.hint)
mergedCount += 1
}

var hints = usableHints.filter {
!removedExistingKeys.contains(CmxIrohPathHintMergeKey($0))
}
hints.append(contentsOf: appendedCandidates)
return (try? CmxAttachRoute(
id: route.id,
kind: route.kind,
endpoint: .peer(identity: identity, pathHints: hints),
priority: route.priority
)) ?? route
}
}

/// Creates one fallback-only Iroh hint from a canonical Tailscale peer.
func irohTailscalePathHint(observedAt: Date) -> CmxIrohPathHint? {
guard kind == .tailscale,
case let .hostPort(host, port) = endpoint,
let address = CmxTailscalePeerAddress(host) else {
return nil
}
let socketAddress: String
switch address.family {
case .ipv4:
socketAddress = "\(address.value):\(port)"
case .ipv6:
socketAddress = "[\(address.value)]:\(port)"
}
return try? CmxIrohPathHint(
kind: .directAddress,
value: socketAddress,
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: observedAt,
expiresAt: observedAt.addingTimeInterval(
CmxIrohPathHint.maximumPrivateHintTTL
),
networkProfile: .activeTailscaleTunnel
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import Foundation
import Testing
@testable import CMUXMobileCore

@Suite struct CmxIrohPrivatePathSynthesizerTests {
private let now = Date(timeIntervalSince1970: 10_000)

@Test func addsFallbackOnlyTailscaleHintWithoutChangingPeerIdentity() throws {
let identity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "a", count: 64)
)
let iroh = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(identity: identity, pathHints: [])
)
let tailscale = try CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "100.82.214.112", port: 50906)
)

let routes = CmxAttachRoute.addingIrohPrivatePaths(
to: [iroh, tailscale],
observedAt: now
)

#expect(routes.map(\.kind) == [.iroh, .tailscale])
guard case let .peer(resultIdentity, hints) = routes[0].endpoint else {
Issue.record("Expected Iroh peer endpoint")
return
}
#expect(resultIdentity == identity)
let hint = try #require(hints.first)
#expect(hint.value == "100.82.214.112:50906")
#expect(hint.source == .tailscale)
#expect(hint.privacyScope == .privateNetwork)
#expect(hint.use == .fallbackOnly)
#expect(hint.observedAt == now)
#expect(hint.expiresAt == now.addingTimeInterval(
CmxIrohPathHint.maximumPrivateHintTTL
))
#expect(hint.networkProfile
== CmxIrohNetworkProfileKey.activeTailscaleTunnel)
}

@Test func ignoresMagicDNSAndGenericPrivateNetworkRoutes() throws {
let iroh = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(
endpointID: String(repeating: "a", count: 64)
),
pathHints: []
)
)
let magicDNS = try CmxAttachRoute(
id: "magic-dns",
kind: .tailscale,
endpoint: .hostPort(host: "work-mac.tailnet.ts.net", port: 50906)
)
let genericLAN = try CmxAttachRoute(
id: "lan",
kind: .tailscale,
endpoint: .hostPort(host: "192.168.1.20", port: 50906)
)

let routes = CmxAttachRoute.addingIrohPrivatePaths(
to: [iroh, magicDNS, genericLAN],
observedAt: now
)

guard case let .peer(_, hints) = routes[0].endpoint else {
Issue.record("Expected Iroh peer endpoint")
return
}
#expect(hints.isEmpty)
}

@Test func refreshReplacesSameAddressInsteadOfAccumulatingHints() throws {
let tailscale = try CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "fd7a:115c:a1e0::4b36:d670", port: 50906)
)
let originalHint = try #require(
tailscale.irohTailscalePathHint(observedAt: now)
)
let iroh = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(
endpointID: String(repeating: "b", count: 64)
),
pathHints: [originalHint]
)
)
let refreshedAt = now.addingTimeInterval(60)

let routes = CmxAttachRoute.addingIrohPrivatePaths(
to: [iroh, tailscale],
observedAt: refreshedAt
)

guard case let .peer(_, hints) = routes[0].endpoint else {
Issue.record("Expected Iroh peer endpoint")
return
}
#expect(hints.count == 1)
#expect(hints[0].value == "[fd7a:115c:a1e0::4b36:d670]:50906")
#expect(hints[0].observedAt == refreshedAt)
}

@Test func expiredHintsDoNotConsumeFreshTailscaleCapacity() throws {
let expiredAt = now.addingTimeInterval(-1)
let expiredHint = try CmxIrohPathHint(
kind: .directAddress,
value: "100.82.214.111:50906",
source: .tailscale,
privacyScope: .privateNetwork,
observedAt: expiredAt.addingTimeInterval(-60),
expiresAt: expiredAt,
networkProfile: .activeTailscaleTunnel
)
let iroh = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(
endpointID: String(repeating: "c", count: 64)
),
pathHints: Array(
repeating: expiredHint,
count: CmxAttachEndpoint.maximumIrohPathHintCount
)
)
)
let tailscale = try CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "100.82.214.112", port: 50906)
)

let routes = CmxAttachRoute.addingIrohPrivatePaths(
to: [iroh, tailscale],
observedAt: now
)

guard case let .peer(_, hints) = routes[0].endpoint else {
Issue.record("Expected Iroh peer endpoint")
return
}
#expect(hints.count == 1)
#expect(hints[0].value == "100.82.214.112:50906")
#expect(hints[0].isUsable(at: now))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,21 @@ extension MobileShellComposite {
///
/// Unlike the legacy host/port helper, this preserves Iroh peer routes. Once
/// a supported Iroh route exists, it also pins the pairing to Iroh and drops
/// every raw host/port fallback. Otherwise an admission or revocation failure
/// could silently downgrade to a Stack-bearer Tailscale request and bypass the
/// Iroh device grant. Legacy Macs that never advertised Iroh keep their raw
/// private-network routes.
/// every raw host/port fallback. A numeric Tailscale route is first copied
/// into the pinned Iroh route as a private fallback address, so Tailscale can
/// still carry Iroh without receiving a Stack bearer. Otherwise an admission
/// or revocation failure could silently downgrade around the Iroh device
/// grant. Pairings without an authenticated Iroh identity remain fail-closed.
static func storedReconnectRoutes(
_ routes: [CmxAttachRoute],
supportedKinds: [CmxAttachTransportKind],
preferNonLoopback: Bool = false
) -> [CmxAttachRoute] {
let supportedKinds = Set(supportedKinds)
var ordered = routes
var ordered = CmxAttachRoute.addingIrohPrivatePaths(
to: routes,
observedAt: Date()
)
.filter { supportedKinds.isEmpty || supportedKinds.contains($0.kind) }
.sorted(by: Self.routeSortsBefore)
if preferNonLoopback, ordered.contains(where: { $0.kind != .debugLoopback }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4821,7 +4821,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
for ticket: CmxAttachTicket,
supportedKinds: [CmxAttachTransportKind]
) -> [CmxAttachRoute] {
let orderedRoutes = ticket.routes.sorted(by: Self.routeSortsBefore)
let orderedRoutes = CmxAttachRoute.addingIrohPrivatePaths(
to: ticket.routes,
observedAt: Date()
).sorted(by: Self.routeSortsBefore)
let supportedRoutes: [CmxAttachRoute]
if supportedKinds.isEmpty {
supportedRoutes = orderedRoutes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,5 +121,14 @@ extension ReconnectRouteSelectionTests {
)

#expect(routes.map(\.kind) == [.iroh])
guard case let .peer(identity, hints) = routes[0].endpoint else {
Issue.record("Expected pinned Iroh route")
return
}
#expect(identity.endpointID == String(repeating: "a", count: 64))
#expect(hints.count == 1)
#expect(hints[0].value == "100.82.214.112:50906")
#expect(hints[0].source == .tailscale)
#expect(hints[0].use == .fallbackOnly)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ actor MobileIrohNetworkPathState {
private var generation: UInt64 = 1
private var lanProfiles: [CmxIrohNetworkProfileKey: UInt32] = [:]
private var observationTask: Task<Void, Never>?
private let networkInterfaces: any NetworkInterfaceAddressProviding

init(
networkInterfaces: any NetworkInterfaceAddressProviding =
SystemNetworkInterfaceAddressProvider()
) {
self.networkInterfaces = networkInterfaces
}

func start(
reachability: any ReachabilityProviding,
Expand All @@ -23,9 +31,15 @@ actor MobileIrohNetworkPathState {
}

func snapshot() -> CmxIrohNetworkPathSnapshot {
CmxIrohNetworkPathSnapshot(
var profiles = Set(lanProfiles.keys)
if TailscaleStatus(
interfaces: networkInterfaces.currentInterfaceAddresses()
) == .active {
profiles.insert(.activeTailscaleTunnel)
}
return CmxIrohNetworkPathSnapshot(
generation: generation,
activeNetworkProfiles: Set(lanProfiles.keys)
activeNetworkProfiles: profiles
)
}

Expand Down
Loading
Loading