diff --git a/ios/Sources/CompanionCore/Failover.swift b/ios/Sources/CompanionCore/Failover.swift index 6efb13f91..b5ce43bb1 100644 --- a/ios/Sources/CompanionCore/Failover.swift +++ b/ios/Sources/CompanionCore/Failover.swift @@ -312,17 +312,36 @@ extension Connection { /// legacy fields because they can represent hosted HTTPS. A connection /// either walks that complete typed set or, for an older desktop, derives /// direct routes from the legacy fields — never a lossy mixture of both. + /// + /// A protected route that already carried the bearer leads when — and + /// only when — the priority sort would otherwise hand the rotation to a + /// cleartext route. Without this, a hand-typed LAN origin (`priority: 0`) + /// wins the sort after restart and the rotation hands the token to + /// cleartext again. But when the sort's head is itself protected (a + /// tailnet invite whose active tailnet route sits behind an advertised + /// hosted HTTPS), the advertised priority order stands. Typing a local + /// address resets `activeEndpoint`, so the priority order remains the + /// escape hatch. public var orderedEndpoints: [CompanionEndpoint] { var candidates = endpoints ?? [] if !candidates.isEmpty { if let activeEndpoint, !candidates.contains(where: { $0.url == activeEndpoint.url }) { candidates.append(activeEndpoint) } - candidates = candidates.enumerated().sorted { + // Route policy is part of candidate selection, not a final display + // filter. A disallowed cleartext route must not trigger the trust + // ratchet and hoist an otherwise lower-priority protected route. + candidates = endpointsAllowedByRoutePolicy(candidates).enumerated().sorted { $0.element.priority == $1.element.priority ? $0.offset < $1.offset : $0.element.priority < $1.element.priority }.map(\.element) + if let activeEndpoint = activeEndpoint.flatMap({ active in + candidates.first(where: { $0.url == active.url }) + }), activeEndpoint.protectsCredentials, + let sortedHead = candidates.first, !sortedHead.protectsCredentials { + candidates = [activeEndpoint] + candidates.filter { $0.url != activeEndpoint.url } + } } else { candidates = orderedHosts.enumerated().compactMap { offset, candidate in CompanionEndpoint.direct(host: candidate, port: port, priority: offset) diff --git a/ios/Tests/CompanionCoreTests/FailoverTests.swift b/ios/Tests/CompanionCoreTests/FailoverTests.swift index e601479e9..85752e9e6 100644 --- a/ios/Tests/CompanionCoreTests/FailoverTests.swift +++ b/ios/Tests/CompanionCoreTests/FailoverTests.swift @@ -244,6 +244,122 @@ final class FailoverTests: XCTestCase { XCTAssertEqual(connection.orderedEndpoints.map(\.url), [hosted.url, lan.url]) } + func testPromotingAProtectedRouteLeadsAfterRestartDespiteAPriorityZeroLocalRoute() throws { + let local = try XCTUnwrap(CompanionEndpoint( + url: "http://192.168.1.42:8810", + kind: .lan, + priority: 0 + )) + let hosted = try XCTUnwrap(CompanionEndpoint( + url: "https://mac.companion.example", + kind: .hosted, + priority: 100 + )) + var connection = Connection( + name: "Mac", + host: local.host, + port: local.port, + activeEndpoint: local, + endpoints: [local, hosted] + ) + + XCTAssertEqual( + connection.orderedEndpoints.map(\.kind), + [.lan, .hosted], + "a hand-typed local route leads until a protected route wins" + ) + + connection.promote(hosted) + + XCTAssertEqual(connection.activeEndpoint, hosted) + XCTAssertEqual( + connection.orderedEndpoints.map(\.kind), + [.hosted, .lan], + "the upgrade must live in the stored order, not only this process's rotation" + ) + XCTAssertEqual( + connection.automaticEndpoints.map(\.kind), + [.hosted], + "the next launch must not retry the superseded cleartext route" + ) + + var persisted = try JSONDecoder().decode( + Connection.self, + from: try JSONEncoder().encode(connection) + ) + XCTAssertEqual(persisted.orderedEndpoints.map(\.kind), [.hosted, .lan]) + let rotation = CandidateRotation(endpoints: persisted.orderedEndpoints) + XCTAssertEqual(rotation.currentEndpoint?.kind, .hosted) + XCTAssertTrue(rotation.endpoints.allSatisfy(\.protectsCredentials)) + + // Typing the LAN address again is the escape hatch when hosted is down. + persisted.resetRoutePolicy(selecting: local) + XCTAssertEqual(persisted.orderedEndpoints.map(\.kind), [.lan, .hosted]) + } + + func testAPriorityPreferredProtectedHeadOutranksTheActiveProtectedRoute() throws { + // A tailnet invite keeps the active tailnet route protected, but the + // desktop advertises its hosted HTTPS with a better priority: the + // trust ratchet must not hoist the active route above another + // protected head — only above a cleartext one. + let hosted = try XCTUnwrap(CompanionEndpoint( + url: "https://mac.companion.example", + kind: .hosted, + priority: 0 + )) + let tailnet = try XCTUnwrap(CompanionEndpoint( + url: "http://mac.tail1234.ts.net:8810", + kind: .tailnet, + priority: 100 + )) + let connection = Connection( + name: "Mac", + host: tailnet.host, + port: tailnet.port, + activeEndpoint: tailnet, + endpoints: [tailnet, hosted] + ) + + XCTAssertEqual( + connection.orderedEndpoints.map(\.kind), + [.hosted, .tailnet], + "an advertised protected head keeps its priority lead over the active route" + ) + XCTAssertEqual(connection.automaticEndpoints.map(\.kind), [.hosted, .tailnet]) + } + + func testADisallowedCleartextHeadCannotHoistTheActiveProtectedRoute() throws { + let lan = try XCTUnwrap(CompanionEndpoint( + url: "http://192.168.1.42:8810", + kind: .lan, + priority: 0 + )) + let hosted = try XCTUnwrap(CompanionEndpoint( + url: "https://mac.companion.example", + kind: .hosted, + priority: 50 + )) + let tailnet = try XCTUnwrap(CompanionEndpoint( + url: "http://mac.tail1234.ts.net:8810", + kind: .tailnet, + priority: 100 + )) + let connection = Connection( + name: "Mac", + host: tailnet.host, + port: tailnet.port, + activeEndpoint: tailnet, + endpoints: [lan, hosted, tailnet], + allowedRouteKinds: [.hosted, .tailnet] + ) + + XCTAssertEqual( + connection.orderedEndpoints.map(\.kind), + [.hosted, .tailnet], + "a route forbidden by policy must not influence protected-route ordering" + ) + } + func testPromotingAWorkingLegacyEndpointKeepsEveryLegacyFallback() throws { var connection = Connection( name: "Mac",