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
21 changes: 20 additions & 1 deletion ios/Sources/CompanionCore/Failover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
116 changes: 116 additions & 0 deletions ios/Tests/CompanionCoreTests/FailoverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading