From ea636feb11723fa7a84b97e911521b7e3d037bc9 Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Wed, 26 Aug 2026 12:53:32 +0000 Subject: [PATCH 1/3] fix(ios): keep a protected companion route first after restart - Problem: after a hand-typed LAN origin (priority 0) is upgraded to hosted/tailnet, orderedEndpoints still sorted by priority, so the next launch handed the bearer to cleartext again. - Fix: when activeEndpoint already protects credentials, lead orderedEndpoints with it so CandidateRotation and automaticCandidates cannot walk back onto the superseded local route. - Verification: python3 reproduction of orderedEndpoints+automaticCandidates (before: lan leads and stays automatic; after: hosted leads and LAN is dropped). New FailoverTests case encodes/decodes the connection and asserts the next rotation starts protected. Swift toolchain is not on this Linux host; CI CompanionCore tests will run the XCTest. --- ios/Sources/CompanionCore/Failover.swift | 9 ++++ .../CompanionCoreTests/FailoverTests.swift | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/ios/Sources/CompanionCore/Failover.swift b/ios/Sources/CompanionCore/Failover.swift index 6efb13f91..a974d0b81 100644 --- a/ios/Sources/CompanionCore/Failover.swift +++ b/ios/Sources/CompanionCore/Failover.swift @@ -312,6 +312,12 @@ 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 regardless of + /// a lower advertised priority. Otherwise a hand-typed LAN origin + /// (`priority: 0`) would win the sort after restart and the rotation + /// would hand the token to cleartext again. Typing a local address + /// resets `activeEndpoint`, so the priority order remains the escape hatch. public var orderedEndpoints: [CompanionEndpoint] { var candidates = endpoints ?? [] if !candidates.isEmpty { @@ -323,6 +329,9 @@ extension Connection { ? $0.offset < $1.offset : $0.element.priority < $1.element.priority }.map(\.element) + if let activeEndpoint, activeEndpoint.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..0cf7f3d34 100644 --- a/ios/Tests/CompanionCoreTests/FailoverTests.swift +++ b/ios/Tests/CompanionCoreTests/FailoverTests.swift @@ -244,6 +244,59 @@ 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 testPromotingAWorkingLegacyEndpointKeepsEveryLegacyFallback() throws { var connection = Connection( name: "Mac", From e5fcd8b406e26314021ffed0ed40a70e1e9abd74 Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Thu, 27 Aug 2026 00:48:13 +0000 Subject: [PATCH 2/3] fix(ios): hoist the protected active route only above cleartext heads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Problem: the trust ratchet hoisted ANY protected activeEndpoint above the priority sort, so a tailnet invite whose desktop also advertises hosted HTTPS (better priority) ordered [tailnet, hosted] after refresh — EndpointRefreshTests.testExplicitTailscaleInviteAllowsTailnetAndHostedAfterRefresh failed on CI, and the rotation preferred the cleartext tailnet route over the advertised HTTPS one. - Fix: hoist the active protected route only when the priority-sorted head is not itself protected; when the head is protected the advertised priority order stands. Added a unit regression mirroring the failing refresh scenario. - Verification: every orderedEndpoints/automaticEndpoints assertion in FailoverTests + EndpointRefreshTests traced against the new condition (no Swift on Linux host); Swift tests + iOS build job on CI is the executable check. --- ios/Sources/CompanionCore/Failover.swift | 17 ++++++---- .../CompanionCoreTests/FailoverTests.swift | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/ios/Sources/CompanionCore/Failover.swift b/ios/Sources/CompanionCore/Failover.swift index a974d0b81..4cf193b95 100644 --- a/ios/Sources/CompanionCore/Failover.swift +++ b/ios/Sources/CompanionCore/Failover.swift @@ -313,11 +313,15 @@ extension 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 regardless of - /// a lower advertised priority. Otherwise a hand-typed LAN origin - /// (`priority: 0`) would win the sort after restart and the rotation - /// would hand the token to cleartext again. Typing a local address - /// resets `activeEndpoint`, so the priority order remains the escape hatch. + /// 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 { @@ -329,7 +333,8 @@ extension Connection { ? $0.offset < $1.offset : $0.element.priority < $1.element.priority }.map(\.element) - if let activeEndpoint, activeEndpoint.protectsCredentials { + if let activeEndpoint, activeEndpoint.protectsCredentials, + let sortedHead = candidates.first, !sortedHead.protectsCredentials { candidates = [activeEndpoint] + candidates.filter { $0.url != activeEndpoint.url } } } else { diff --git a/ios/Tests/CompanionCoreTests/FailoverTests.swift b/ios/Tests/CompanionCoreTests/FailoverTests.swift index 0cf7f3d34..1b94cd872 100644 --- a/ios/Tests/CompanionCoreTests/FailoverTests.swift +++ b/ios/Tests/CompanionCoreTests/FailoverTests.swift @@ -297,6 +297,37 @@ final class FailoverTests: XCTestCase { 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 testPromotingAWorkingLegacyEndpointKeepsEveryLegacyFallback() throws { var connection = Connection( name: "Mac", From 7e1eaa1ad7437b6faddaa71cb5fb590676214d61 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Thu, 27 Aug 2026 09:27:39 +0530 Subject: [PATCH 3/3] fix(ios): filter route policy before trust ordering --- ios/Sources/CompanionCore/Failover.swift | 9 ++++-- .../CompanionCoreTests/FailoverTests.swift | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/ios/Sources/CompanionCore/Failover.swift b/ios/Sources/CompanionCore/Failover.swift index 4cf193b95..b5ce43bb1 100644 --- a/ios/Sources/CompanionCore/Failover.swift +++ b/ios/Sources/CompanionCore/Failover.swift @@ -328,12 +328,17 @@ extension Connection { 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.protectsCredentials, + 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 } } diff --git a/ios/Tests/CompanionCoreTests/FailoverTests.swift b/ios/Tests/CompanionCoreTests/FailoverTests.swift index 1b94cd872..85752e9e6 100644 --- a/ios/Tests/CompanionCoreTests/FailoverTests.swift +++ b/ios/Tests/CompanionCoreTests/FailoverTests.swift @@ -328,6 +328,38 @@ final class FailoverTests: XCTestCase { 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",