Files
openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift
Peter Steinberger 0dbdf994b3 feat(macos): isolate named app profiles (#121136)
* feat(macos): isolate named app profiles

* refactor(macos): isolate profile launch ownership

* fix(macos): avoid overlapping approvals socket access

* fix(macos): declare profile defaults concurrency ownership

* fix(macos): return profiled node launch arguments

* chore(i18n): refresh macOS profile source inventory

* fix(macos): gate profile startup before services

* test(macos): evaluate profile state before assertions

* fix(daemon): skip absent launchd deactivation

* fix(macos): fail closed on profile port conflicts

* chore(i18n): refresh profile conflict inventory

* fix(macos): ignore non-gateway launch agent claims

* test(macos): stabilize profile lifecycle timing

* fix(macos): remove stale dashboard URL

* chore(macos): refresh native source baseline
2026-08-09 14:50:15 -07:00

1244 lines
49 KiB
Swift

import ConcurrencyExtras
import Foundation
import Testing
@testable import OpenClaw
@testable import OpenClawKit
private actor GatewayEndpointSourceGate {
private var current: GatewayEndpointStore.SourceSnapshot
private var readCount = 0
private var suspendNext = false
private var returnCapturedSource = false
private var suspendedReadStarted = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiter: CheckedContinuation<Void, Never>?
init(_ source: GatewayEndpointStore.SourceSnapshot) {
self.current = source
}
func snapshot() async -> GatewayEndpointStore.SourceSnapshot {
self.readCount += 1
guard self.suspendNext else { return self.current }
self.suspendNext = false
let capturedSource = self.returnCapturedSource ? self.current : nil
self.returnCapturedSource = false
self.suspendedReadStarted = true
for waiter in self.startWaiters {
waiter.resume()
}
self.startWaiters.removeAll()
await withCheckedContinuation { continuation in
self.releaseWaiter = continuation
}
return capturedSource ?? self.current
}
func reads() -> Int {
self.readCount
}
func suspendNextRead(returningCapturedSource: Bool = false) {
self.suspendNext = true
self.returnCapturedSource = returningCapturedSource
self.suspendedReadStarted = false
}
func update(_ source: GatewayEndpointStore.SourceSnapshot) {
self.current = source
}
func waitUntilSuspendedReadStarts() async {
guard !self.suspendedReadStarted else { return }
await withCheckedContinuation { continuation in
self.startWaiters.append(continuation)
}
}
func releaseSuspendedRead() {
self.releaseWaiter?.resume()
self.releaseWaiter = nil
}
}
private actor GatewayEndpointRouteLookupGate {
private var started = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiter: CheckedContinuation<Void, Never>?
func lookup() async -> RemoteTunnelManager.Route? {
self.started = true
for waiter in self.startWaiters {
waiter.resume()
}
self.startWaiters.removeAll()
await withCheckedContinuation { continuation in
self.releaseWaiter = continuation
}
return nil
}
func waitUntilStarted() async {
guard !self.started else { return }
await withCheckedContinuation { continuation in
self.startWaiters.append(continuation)
}
}
func release() {
self.releaseWaiter?.resume()
self.releaseWaiter = nil
}
}
private actor GatewayEndpointRemoteEnsureGate {
private let route: RemoteTunnelManager.Route
private var installed = false
private var lookupCount = 0
private var ensureStarted = false
private var lookupWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
private var ensureStartWaiters: [CheckedContinuation<Void, Never>] = []
private var ensureReleaseWaiter: CheckedContinuation<Void, Never>?
init(route: RemoteTunnelManager.Route) {
self.route = route
}
func routeIfRunning() -> RemoteTunnelManager.Route? {
self.lookupCount += 1
let ready = self.lookupWaiters.filter { self.lookupCount >= $0.count }
self.lookupWaiters.removeAll { self.lookupCount >= $0.count }
for waiter in ready {
waiter.continuation.resume()
}
return self.installed ? self.route : nil
}
func isCurrent(_ route: RemoteTunnelManager.Route) -> Bool {
self.installed && route == self.route
}
func ensure() async -> RemoteTunnelManager.Route {
self.ensureStarted = true
for waiter in self.ensureStartWaiters {
waiter.resume()
}
self.ensureStartWaiters.removeAll()
await withCheckedContinuation { continuation in
self.ensureReleaseWaiter = continuation
}
self.installed = true
return self.route
}
func waitUntilLookupCount(_ count: Int) async {
guard self.lookupCount < count else { return }
await withCheckedContinuation { continuation in
self.lookupWaiters.append((count, continuation))
}
}
func waitUntilEnsureStarts() async {
guard !self.ensureStarted else { return }
await withCheckedContinuation { continuation in
self.ensureStartWaiters.append(continuation)
}
}
func releaseEnsure() {
self.ensureReleaseWaiter?.resume()
self.ensureReleaseWaiter = nil
}
}
struct GatewayEndpointStoreTests {
@MainActor
@Test func `live local source uses canonical default and named profile ports`() async throws {
let configPath = TestIsolation.tempConfigPath()
try Data(#"{"gateway":{"mode":"local"}}"#.utf8)
.write(to: URL(fileURLWithPath: configPath))
defer { try? FileManager.default.removeItem(atPath: configPath) }
try await TestIsolation.withIsolatedState(
env: [
"OPENCLAW_CONFIG_PATH": configPath,
"OPENCLAW_GATEWAY_PORT": nil,
],
defaults: ["gatewayPort": nil])
{
let state = AppState(preview: true)
let base = await GatewayEndpointStore._testLiveSourceSnapshot(
state: state,
profile: AppProfile(environment: [:]),
beforeConfigRead: {})
let workProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
let work = await GatewayEndpointStore._testLiveSourceSnapshot(
state: state,
profile: workProfile,
beforeConfigRead: {})
#expect(base.localPort == 18789)
#expect(work.localPort == workProfile.defaultGatewayPort)
}
}
private func makeLaunchAgentSnapshot(
env: [String: String] = [:],
token: String? = nil,
password: String? = nil) -> LaunchAgentPlistSnapshot
{
LaunchAgentPlistSnapshot(
programArguments: [],
environment: env,
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: token,
password: password)
}
private func makeDefaults() -> UserDefaults {
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
private func makeLaunchAgentTokenSnapshot(_ token: String) -> LaunchAgentPlistSnapshot {
self.makeLaunchAgentSnapshot(env: ["OPENCLAW_GATEWAY_TOKEN": token], token: token)
}
private func makeLaunchAgentPasswordSnapshot(_ password: String) -> LaunchAgentPlistSnapshot {
self.makeLaunchAgentSnapshot(env: ["OPENCLAW_GATEWAY_PASSWORD": password], password: password)
}
private func source(
mode: AppState.ConnectionMode,
token: String? = nil,
password: String? = nil,
localHost: String = "127.0.0.1",
bindMode: String? = "loopback",
transport: AppState.RemoteTransport = .ssh,
directURL: URL? = nil,
tlsFingerprint: String? = nil,
deviceAuthGatewayID: String = "test-gateway-route",
routingGeneration: UInt64? = nil) -> GatewayEndpointStore.SourceSnapshot
{
GatewayEndpointStore.SourceSnapshot(
routingGeneration: routingGeneration,
mode: .init(mode),
token: token,
password: password,
deviceAuthGatewayID: deviceAuthGatewayID,
localPort: 18789,
localHost: localHost,
scheme: "ws",
bindMode: bindMode,
remoteTransport: .init(transport),
directRemoteURL: directURL,
remoteTLSFingerprint: tlsFingerprint,
sshRouteIdentity: mode == .remote && transport == .ssh
? .init(
target: "user@gateway.example",
identity: "",
hostKeyPolicy: "strict",
configuredRemotePort: nil,
configuredRemoteURL: nil)
: nil)
}
private func localAuthRoot(_ key: String, value: String) -> [String: Any] {
["gateway": ["auth": [key: value]]]
}
private func makeStore(
sourceSnapshot: @escaping @Sendable () async -> GatewayEndpointStore.SourceSnapshot,
token: @escaping @Sendable () -> String? = { nil },
remoteRouteIfRunning: @escaping @Sendable () async -> RemoteTunnelManager.Route? = { nil },
remoteRouteIsCurrent: @escaping @Sendable (RemoteTunnelManager.Route) async -> Bool = { _ in true },
canStartRemoteTunnel: @escaping @Sendable () -> Bool = { true },
ensureRemoteTunnel: @escaping @Sendable () async throws -> RemoteTunnelManager.Route = {
throw CancellationError()
},
liveSourceIsCurrent: @escaping @Sendable (GatewayEndpointStore.SourceSnapshot) async -> Bool = { _ in true })
-> GatewayEndpointStore
{
GatewayEndpointStore(deps: .init(
token: token,
password: { nil },
localPort: { 18789 },
localUnavailableReason: { nil },
remoteRouteIfRunning: remoteRouteIfRunning,
remoteRouteIsCurrent: remoteRouteIsCurrent,
canStartRemoteTunnel: canStartRemoteTunnel,
ensureRemoteTunnel: ensureRemoteTunnel,
liveSourceIsCurrent: liveSourceIsCurrent,
sourceSnapshot: sourceSnapshot))
}
private func resolveMode(
configMode: String? = nil,
storedMode: String,
remoteURL: String? = nil) -> EffectiveConnectionMode
{
let defaults = self.makeDefaults()
defaults.set(storedMode, forKey: connectionModeKey)
var gateway: [String: Any] = [:]
if let configMode {
gateway["mode"] = configMode
}
if let remoteURL {
gateway["remote"] = ["url": remoteURL]
}
let root: [String: Any] = gateway.isEmpty ? [:] : ["gateway": gateway]
return ConnectionModeResolver.resolve(root: root, defaults: defaults)
}
@Test func `local conflict remains unavailable across refresh until cleared`() async throws {
let source = self.source(mode: .local)
let store = self.makeStore(sourceSnapshot: { source })
await store.setLocalUnavailableReason("Profile port conflict")
await store.refresh()
#expect(await store.currentState() == .unavailable(
mode: .local,
reason: "Profile port conflict"))
await #expect(throws: Error.self) {
_ = try await store.requireEndpoint()
}
await store.setLocalUnavailableReason(nil)
await store.refresh()
guard case .ready = await store.currentState() else {
Issue.record("Expected local endpoint to recover after conflict clears")
return
}
}
private func dashboardURL(
_ endpoint: String,
mode: AppState.ConnectionMode,
localBasePath: String,
token: String? = nil,
password: String? = nil,
authToken: String? = nil) throws -> URL
{
let config: GatewayConnection.Config = try (
url: #require(URL(string: endpoint)),
token: token,
password: password)
return try GatewayEndpointStore.dashboardURL(
for: config,
mode: mode,
localBasePath: localBasePath,
authToken: authToken)
}
@Test func `resolve gateway token prefers env and falls back to launchd`() {
let snapshot = self.makeLaunchAgentTokenSnapshot("launchd-token")
let envToken = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: [:],
env: ["OPENCLAW_GATEWAY_TOKEN": "env-token"],
launchdSnapshot: snapshot)
#expect(envToken == "env-token")
let fallbackToken = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(fallbackToken == "launchd-token")
}
@Test func `resolve gateway token skips unresolved env template before launchd fallback`() throws {
let snapshot = self.makeLaunchAgentTokenSnapshot("launchd-token")
let root = self.localAuthRoot("token", value: "${OPENCLAW_GATEWAY_TOKEN}")
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: root,
env: [:],
launchdSnapshot: snapshot)
#expect(token == "launchd-token")
let url = try self.dashboardURL(
"ws://127.0.0.1:18789",
mode: .local,
localBasePath: "/control",
token: token)
#expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=launchd-token")
}
@Test func `resolve gateway token skips unresolved env shorthand before launchd fallback`() {
let snapshot = self.makeLaunchAgentTokenSnapshot("launchd-token")
let root = self.localAuthRoot("token", value: "$OPENCLAW_GATEWAY_TOKEN")
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: root,
env: [:],
launchdSnapshot: snapshot)
#expect(token == "launchd-token")
}
@Test func `resolve gateway token resolves env template from app environment`() {
let snapshot = self.makeLaunchAgentSnapshot(
env: [
"CUSTOM_GATEWAY_TOKEN": "service-token",
"OPENCLAW_GATEWAY_TOKEN": "launchd-token",
],
token: "launchd-token")
let root = self.localAuthRoot("token", value: "${CUSTOM_GATEWAY_TOKEN}")
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: root,
env: ["CUSTOM_GATEWAY_TOKEN": " custom-token "],
launchdSnapshot: snapshot)
#expect(token == "custom-token")
}
@Test func `resolve gateway token resolves env template from gateway service environment`() {
let snapshot = self.makeLaunchAgentSnapshot(
env: ["CUSTOM_GATEWAY_TOKEN": " service-token "])
let root = self.localAuthRoot("token", value: "${CUSTOM_GATEWAY_TOKEN}")
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: root,
env: ["CUSTOM_GATEWAY_TOKEN": " "],
launchdSnapshot: snapshot)
#expect(token == "service-token")
}
@Test func `resolve gateway token keeps invalid env template as plaintext`() {
let snapshot = self.makeLaunchAgentTokenSnapshot("launchd-token")
let root = self.localAuthRoot("token", value: "${custom_gateway_token}")
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: root,
env: ["custom_gateway_token": "custom-token"],
launchdSnapshot: snapshot)
#expect(token == "${custom_gateway_token}")
}
@Test func `resolve gateway token omits unresolved env template without fallback`() throws {
let root = self.localAuthRoot("token", value: "${OPENCLAW_GATEWAY_TOKEN}")
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: root,
env: [:],
launchdSnapshot: nil)
#expect(token == nil)
let url = try self.dashboardURL(
"ws://127.0.0.1:18789",
mode: .local,
localBasePath: "/control",
token: token)
#expect(url.absoluteString == "http://127.0.0.1:18789/control/")
}
@Test func `resolve gateway token ignores launchd in remote mode`() {
let snapshot = self.makeLaunchAgentTokenSnapshot("launchd-token")
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: true,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(token == nil)
}
@Test func `resolve gateway token uses remote config token`() {
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: true,
root: [
"gateway": [
"remote": [
"token": " remote-token ",
],
],
],
env: [:],
launchdSnapshot: nil)
#expect(token == "remote-token")
}
@Test func `remote password resolver trims remote config password`() {
let root: [String: Any] = [
"gateway": [
"remote": [
"password": " remote-pass ",
],
],
]
#expect(GatewayRemoteConfig.resolvePasswordString(root: root) == "remote-pass")
}
@Test func `resolve gateway password falls back to launchd`() {
let snapshot = self.makeLaunchAgentPasswordSnapshot("launchd-pass")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
}
@Test func `resolve gateway password skips unresolved env template before launchd fallback`() {
let snapshot = self.makeLaunchAgentPasswordSnapshot("launchd-pass")
let root = self.localAuthRoot("password", value: "${OPENCLAW_GATEWAY_PASSWORD}")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: root,
env: [:],
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
}
@Test func `resolve gateway password skips unresolved env shorthand before launchd fallback`() {
let snapshot = self.makeLaunchAgentPasswordSnapshot("launchd-pass")
let root = self.localAuthRoot("password", value: "$OPENCLAW_GATEWAY_PASSWORD")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: root,
env: [:],
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
}
@Test func `resolve gateway password resolves env template from gateway service environment`() {
let snapshot = self.makeLaunchAgentSnapshot(
env: ["CUSTOM_GATEWAY_PASSWORD": " service-pass "])
let root = self.localAuthRoot("password", value: "${CUSTOM_GATEWAY_PASSWORD}")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: root,
env: [:],
launchdSnapshot: snapshot)
#expect(password == "service-pass")
}
@Test func `connection mode resolver prefers config mode over defaults`() {
let resolved = self.resolveMode(configMode: " local ", storedMode: "remote")
#expect(resolved.mode == .local)
}
@Test func `connection mode resolver trims config mode`() {
let resolved = self.resolveMode(configMode: " remote ", storedMode: "local")
#expect(resolved.mode == .remote)
}
@Test func `connection mode resolver falls back to defaults when missing config`() {
let resolved = self.resolveMode(storedMode: "remote")
#expect(resolved.mode == .remote)
}
@Test func `connection mode resolver falls back to defaults on unknown config`() {
let resolved = self.resolveMode(configMode: "staging", storedMode: "local")
#expect(resolved.mode == .local)
}
@Test func `connection mode resolver prefers remote URL when mode missing`() {
let resolved = self.resolveMode(storedMode: "local", remoteURL: " ws://umbrel:18789 ")
#expect(resolved.mode == .remote)
}
}
extension GatewayEndpointStoreTests {
@Test func `remote tunnel waits for primary app launch admission`() async {
await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let admitted = LockIsolated(false)
let tunnelStarts = LockIsolated(0)
let source = self.source(mode: .remote, transport: .ssh)
let store = self.makeStore(
sourceSnapshot: { source },
canStartRemoteTunnel: { admitted.withValue { $0 } },
ensureRemoteTunnel: {
tunnelStarts.withValue { $0 += 1 }
return .init(localPort: 18789, generation: 1)
})
await store.refresh()
#expect(tunnelStarts.withValue { $0 } == 0)
admitted.withValue { $0 = true }
let port = try? await store.ensureRemoteControlTunnel()
#expect(port == 18789)
#expect(tunnelStarts.withValue { $0 } == 1)
}
}
@Test func `concurrent endpoint reads for the same selection both succeed`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let source = self.source(mode: .local, token: "same-token")
let sourceGate = GatewayEndpointSourceGate(source)
await sourceGate.suspendNextRead()
let store = self.makeStore(sourceSnapshot: { await sourceGate.snapshot() })
let first = Task { try await store.requireEndpoint() }
await sourceGate.waitUntilSuspendedReadStarts()
let second = Task { try await store.requireEndpoint() }
let secondEndpoint = try await second.value
await sourceGate.releaseSuspendedRead()
let firstEndpoint = try await first.value
#expect(firstEndpoint.config.url == secondEndpoint.config.url)
#expect(firstEndpoint.config.token == "same-token")
#expect(firstEndpoint.revision == secondEndpoint.revision)
}
}
@Test func `routing generation avoids redundant source reads within each request`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let source = self.source(mode: .local, token: "same-token", routingGeneration: 7)
let sourceGate = GatewayEndpointSourceGate(source)
let store = self.makeStore(
sourceSnapshot: { await sourceGate.snapshot() },
liveSourceIsCurrent: { $0.routingGeneration == 7 })
_ = try await store.requireEndpoint()
#expect(await sourceGate.reads() == 1)
_ = try await store.requireEndpoint()
#expect(await sourceGate.reads() == 2)
}
}
@Test func `live source validation rejects tailnet address changes`() {
let source = self.source(
mode: .local,
localHost: "100.64.1.5",
bindMode: "tailnet",
routingGeneration: 7)
#expect(GatewayEndpointStore._testLiveSourceIsCurrent(
source,
currentRoutingGeneration: 7,
currentTailnetIP: "100.64.1.5"))
#expect(!GatewayEndpointStore._testLiveSourceIsCurrent(
source,
currentRoutingGeneration: 7,
currentTailnetIP: "100.64.1.6"))
#expect(!GatewayEndpointStore._testLiveSourceIsCurrent(
source,
currentRoutingGeneration: 8,
currentTailnetIP: "100.64.1.5"))
}
@Test func `remote TLS fingerprint changes advance endpoint revision`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let url = try #require(URL(string: "wss://gateway.example.invalid"))
let sourceA = self.source(
mode: .remote,
transport: .direct,
directURL: url,
tlsFingerprint: String(repeating: "a", count: 64))
let sourceGate = GatewayEndpointSourceGate(sourceA)
let store = self.makeStore(sourceSnapshot: { await sourceGate.snapshot() })
let first = try await store.requireEndpoint()
await sourceGate.update(self.source(
mode: .remote,
transport: .direct,
directURL: url,
tlsFingerprint: String(repeating: "b", count: 64)))
let second = try await store.requireEndpoint()
let firstRevision = try #require(first.revision)
let secondRevision = try #require(second.revision)
#expect(first.tls?.params.expectedFingerprint == String(repeating: "a", count: 64))
#expect(second.tls?.params.expectedFingerprint == String(repeating: "b", count: 64))
#expect(secondRevision > firstRevision)
}
}
@Test func `persisting active first use pin keeps endpoint revision stable`() async throws {
try await withFakeGatewayTLSKeychain {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let url = try #require(URL(string: "wss://gateway.example.invalid"))
let storeKey = GatewayTLSRoute.storeKey(for: url)
let source = self.source(
mode: .remote,
transport: .direct,
directURL: url)
let store = self.makeStore(sourceSnapshot: { source })
let first = try await store.requireEndpoint()
let fingerprint = String(repeating: "a", count: 64)
_ = GatewayTLSStore.claimFirstUseFingerprint(fingerprint, stableID: storeKey)
let second = try await store.requireEndpoint()
#expect(first.revision == second.revision)
#expect(second.tls?.params.allowTOFU == false)
#expect(second.tls?.params.expectedFingerprint == fingerprint)
#expect(GatewayTLSRoute.hasSameConnectionIdentity(first.tls, second.tls))
}
}
}
@Test func `require endpoint rejects a source superseded by a different selection`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let sourceA = self.source(mode: .remote, token: "token-a", transport: .ssh)
let remoteURL = try #require(URL(string: "ws://192.168.1.20:18789"))
let sourceB = self.source(
mode: .remote,
token: "token-b",
password: "password-b",
transport: .direct,
directURL: remoteURL)
let sourceGate = GatewayEndpointSourceGate(sourceA)
let routeGate = GatewayEndpointRouteLookupGate()
let store = self.makeStore(
sourceSnapshot: { await sourceGate.snapshot() },
remoteRouteIfRunning: { await routeGate.lookup() })
let staleRequest = Task { try await store.requireEndpoint() }
await routeGate.waitUntilStarted()
await sourceGate.update(sourceB)
let currentEndpoint = try await store.requireEndpoint()
await routeGate.release()
await #expect(throws: CancellationError.self) {
try await staleRequest.value
}
#expect(currentEndpoint.config.url == remoteURL)
#expect(currentEndpoint.config.token == "token-b")
#expect(currentEndpoint.config.password == "password-b")
}
}
@Test func `require endpoint rejects a generation superseded after source read`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let remoteURL = try #require(URL(string: "ws://192.168.1.20:18789"))
let sourceA = self.source(
mode: .remote,
token: "same-token",
transport: .direct,
directURL: remoteURL,
routingGeneration: 1)
let sourceB = self.source(
mode: .remote,
token: "same-token",
transport: .direct,
directURL: remoteURL,
routingGeneration: 2)
let currentRoutingGeneration = LockIsolated<UInt64>(1)
let sourceGate = GatewayEndpointSourceGate(sourceA)
await sourceGate.suspendNextRead(returningCapturedSource: true)
let store = self.makeStore(
sourceSnapshot: { await sourceGate.snapshot() },
liveSourceIsCurrent: { source in
currentRoutingGeneration.withValue { $0 == source.routingGeneration }
})
let staleRequest = Task { try await store.requireEndpoint() }
await sourceGate.waitUntilSuspendedReadStarts()
currentRoutingGeneration.withValue { $0 = 2 }
await sourceGate.releaseSuspendedRead()
await #expect(throws: CancellationError.self) {
try await staleRequest.value
}
await sourceGate.update(sourceB)
let currentEndpoint = try await store.requireEndpoint()
#expect(currentEndpoint.config.url == remoteURL)
#expect(currentEndpoint.config.token == "same-token")
}
}
@Test func `require endpoint rejects cancellation during endpoint resolution`() async {
await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let sourceGate = GatewayEndpointSourceGate(self.source(mode: .local))
await sourceGate.suspendNextRead()
let store = self.makeStore(sourceSnapshot: { await sourceGate.snapshot() })
let request = Task { try await store.requireEndpoint() }
await sourceGate.waitUntilSuspendedReadStarts()
request.cancel()
await sourceGate.releaseSuspendedRead()
await #expect(throws: CancellationError.self) {
try await request.value
}
}
}
@Test func `tailnet fallback cannot overwrite a replacement remote selection`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "local"]) {
let fallbackSource = self.source(
mode: .local,
token: "local-token",
localHost: "100.64.1.8",
bindMode: "tailnet")
let remoteURL = try #require(URL(string: "ws://192.168.1.20:18789"))
let remoteSource = self.source(
mode: .remote,
token: "remote-token",
transport: .direct,
directURL: remoteURL)
let sourceGate = GatewayEndpointSourceGate(fallbackSource)
let store = self.makeStore(
sourceSnapshot: { await sourceGate.snapshot() },
token: { "local-token" })
let initialURL = try #require(URL(string: "ws://127.0.0.1:18789"))
await sourceGate.suspendNextRead()
let fallback = Task { await store.maybeFallbackToTailnet(from: initialURL) }
await sourceGate.waitUntilSuspendedReadStarts()
await sourceGate.update(remoteSource)
let remoteEndpoint = try await store.requireEndpoint()
await sourceGate.releaseSuspendedRead()
#expect(await fallback.value == nil)
#expect(remoteEndpoint.config.url == remoteURL)
#expect(remoteEndpoint.config.token == "remote-token")
let currentEndpoint = try await store.requireEndpoint()
#expect(currentEndpoint.config.url == remoteURL)
}
}
@Test func `same URL owner replacement publishes a new route revision`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let url = try #require(URL(string: "wss://gateway.example.test"))
let sourceA = self.source(
mode: .remote,
transport: .direct,
directURL: url,
deviceAuthGatewayID: "route-a")
let sourceB = self.source(
mode: .remote,
transport: .direct,
directURL: url,
deviceAuthGatewayID: "route-b")
let sourceGate = GatewayEndpointSourceGate(sourceA)
let store = self.makeStore(sourceSnapshot: { await sourceGate.snapshot() })
let stream = await store.subscribe(bufferingNewest: 10)
var iterator = stream.makeAsyncIterator()
_ = await iterator.next()
let endpointA = try await store.requireEndpoint()
let stateA = await iterator.next()
await sourceGate.update(sourceB)
let endpointB = try await store.requireEndpoint()
let stateB = await iterator.next()
#expect(endpointA.config.url == endpointB.config.url)
#expect(endpointA.revision != endpointB.revision)
guard let stateA,
let stateB,
case let .ready(_, _, _, _, revisionA) = stateA,
case let .ready(_, _, _, _, revisionB) = stateB
else {
Issue.record("expected ready route revisions")
return
}
#expect(revisionA == endpointA.revision)
#expect(revisionB == endpointB.revision)
#expect(revisionA != revisionB)
}
}
@Test func `cancelling one remote waiter does not poison a shared tunnel ensure`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let source = self.source(
mode: .remote,
token: "remote-token",
transport: .ssh)
let remoteGate = GatewayEndpointRemoteEnsureGate(
route: .init(localPort: 28789, generation: 7))
let store = self.makeStore(
sourceSnapshot: { source },
remoteRouteIfRunning: { await remoteGate.routeIfRunning() },
remoteRouteIsCurrent: { await remoteGate.isCurrent($0) },
ensureRemoteTunnel: { await remoteGate.ensure() })
let cancelledWaiter = Task { try await store.requireEndpoint() }
await remoteGate.waitUntilEnsureStarts()
let currentWaiter = Task { try await store.requireEndpoint() }
await remoteGate.waitUntilLookupCount(2)
await Task.yield()
await Task.yield()
cancelledWaiter.cancel()
await remoteGate.releaseEnsure()
await #expect(throws: CancellationError.self) {
try await cancelledWaiter.value
}
let endpoint = try await currentWaiter.value
#expect(endpoint.config.url.absoluteString == "ws://127.0.0.1:28789")
#expect(endpoint.config.token == "remote-token")
#expect(endpoint.routeAuthority == 7)
let reused = try await store.requireEndpoint()
#expect(reused.routeAuthority == 7)
}
}
@Test func `concurrent remote waiters join one tunnel ensure and both succeed`() async throws {
try await TestIsolation.withUserDefaultsValues([connectionModeKey: "unconfigured"]) {
let source = self.source(
mode: .remote,
token: "remote-token",
transport: .ssh)
let remoteGate = GatewayEndpointRemoteEnsureGate(
route: .init(localPort: 28789, generation: 9))
let store = self.makeStore(
sourceSnapshot: { source },
remoteRouteIfRunning: { await remoteGate.routeIfRunning() },
remoteRouteIsCurrent: { await remoteGate.isCurrent($0) },
ensureRemoteTunnel: { await remoteGate.ensure() })
let first = Task { try await store.requireEndpoint() }
await remoteGate.waitUntilEnsureStarts()
let second = Task { try await store.requireEndpoint() }
await remoteGate.waitUntilLookupCount(2)
await Task.yield()
await Task.yield()
await remoteGate.releaseEnsure()
let firstEndpoint = try await first.value
let secondEndpoint = try await second.value
#expect(firstEndpoint.config.url == secondEndpoint.config.url)
#expect(firstEndpoint.routeAuthority == 9)
#expect(firstEndpoint.revision == secondEndpoint.revision)
}
}
@Test func `resolve local gateway host uses loopback for auto even with tailnet`() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "127.0.0.1")
}
@Test func `resolve local gateway host uses loopback for auto without tailnet`() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func `resolve local gateway host prefers tailnet for tailnet mode`() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func `resolve local gateway host falls back to loopback for tailnet mode`() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func `resolve local gateway host uses custom bind host`() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
@Test func `local config uses local gateway auth and host resolution`() {
let snapshot = self.makeLaunchAgentSnapshot(
env: [:],
token: "launchd-token",
password: "launchd-pass")
let root: [String: Any] = [
"gateway": [
"bind": "tailnet",
"tls": ["enabled": true],
"remote": [
"url": "wss://remote.example:443",
"token": "remote-token",
],
],
]
let config = GatewayEndpointStore._testLocalConfig(
root: root,
env: [:],
launchdSnapshot: snapshot,
tailscaleIP: "100.64.1.8")
#expect(config.url.absoluteString == "wss://100.64.1.8:\(GatewayEnvironment.gatewayPort())")
#expect(config.token == "launchd-token")
#expect(config.password == "launchd-pass")
}
@Test func `dashboard URL uses local base path in local mode`() throws {
let url = try self.dashboardURL(
"ws://127.0.0.1:18789",
mode: .local,
localBasePath: " control ")
#expect(url.absoluteString == "http://127.0.0.1:18789/control/")
}
@Test func `dashboard URL skips local base path in remote mode`() throws {
let url = try self.dashboardURL(
"ws://gateway.example:18789",
mode: .remote,
localBasePath: "/local-ui")
#expect(url.absoluteString == "http://gateway.example:18789/")
}
@Test func `dashboard URL prefers path from config URL`() throws {
let url = try self.dashboardURL(
"wss://gateway.example:443/remote-ui",
mode: .remote,
localBasePath: "/local-ui")
#expect(url.absoluteString == "https://gateway.example:443/remote-ui/")
}
@Test func `dashboard URL uses fragment token and omits password`() throws {
let url = try self.dashboardURL(
"ws://127.0.0.1:18789",
mode: .local,
localBasePath: "/control",
token: "abc123",
password: "sekret") // pragma: allowlist secret
#expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=abc123")
#expect(url.query == nil)
}
@Test func `dashboard URL can use native auth token override`() throws {
let url = try self.dashboardURL(
"ws://127.0.0.1:18789",
mode: .local,
localBasePath: "/control",
password: "sekret", // pragma: allowlist secret
authToken: "device-token")
#expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=device-token")
#expect(url.query == nil)
}
@Test func `normalize gateway url adds default port for loopback ws`() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1")
#expect(url?.port == 18789)
#expect(url?.absoluteString == "ws://127.0.0.1:18789")
}
@Test func `normalize gateway url accepts private network ws`() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://192.168.0.202:18789")
#expect(url?.absoluteString == "ws://192.168.0.202:18789")
}
@Test func `normalize gateway url accepts tailnet ws`() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://100.123.224.76:18789")
#expect(url?.absoluteString == "ws://100.123.224.76:18789")
}
@Test func `gateway url validation guidance matches trusted plaintext policy`() {
let accepted = [
"ws://localhost:18789",
"ws://192.168.0.202:18789",
"ws://169.254.20.1:18789",
"ws://gateway.local:18789",
"ws://gateway.example.ts.net:18789",
"ws://100.123.224.76:18789",
]
for rawURL in accepted {
#expect(GatewayRemoteConfig.normalizeGatewayUrl(rawURL) != nil)
}
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789") == nil)
let message = GatewayRemoteConfig.directGatewayUrlValidationMessage
#expect(message.contains("public hosts"))
#expect(message.contains("localhost"))
#expect(message.contains("LAN"))
#expect(message.contains("link-local"))
#expect(message.contains(".local"))
#expect(message.contains("Tailnet"))
#expect(!message.contains("only for localhost"))
}
@Test func `missing transport infers direct from private remote URL`() {
let root: [String: Any] = [
"gateway": [
"remote": [
"url": "ws://192.168.0.202:18789",
],
],
]
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
#expect(resolution.transport == .direct)
#expect(resolution.source == .inferredRemoteURL)
#expect(resolution.directURL?.absoluteString == "ws://192.168.0.202:18789")
}
@Test func `legacy loopback URL keeps SSH even with trusted SSH target`() {
let root: [String: Any] = [
"gateway": [
"remote": [
"url": "ws://127.0.0.1:18789",
"sshTarget": "steipete@192.168.0.202",
],
],
]
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
#expect(resolution.transport == .ssh)
#expect(resolution.source == .legacySSH)
#expect(resolution.directURL == nil)
}
@Test func `explicit ssh keeps legacy tunnel even when target is direct capable`() {
let root: [String: Any] = [
"gateway": [
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "steipete@192.168.0.202",
],
],
]
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
#expect(resolution.transport == .ssh)
#expect(resolution.source == .explicit)
#expect(resolution.directURL == nil)
}
@Test func `ssh tunnel reuse requires the complete captured route configuration`() throws {
let targetA = try #require(CommandResolver.parseSSHTarget("alice@gateway-a.example:22"))
let equivalentTargetA = try #require(CommandResolver.parseSSHTarget("alice@gateway-a.example"))
let targetB = try #require(CommandResolver.parseSSHTarget("bob@gateway-b.example:2200"))
let routeA = RemotePortTunnel.Configuration(
target: targetA,
identity: "/tmp/id-a",
remotePort: 18789,
hostKeyPolicy: .strict)
#expect(RemoteTunnelManager._testCanReuse(routeA, for: routeA))
#expect(RemoteTunnelManager._testCanReuse(
routeA,
for: .init(
target: equivalentTargetA,
identity: routeA.identity,
remotePort: routeA.remotePort,
hostKeyPolicy: routeA.hostKeyPolicy)))
#expect(!RemoteTunnelManager._testCanReuse(
routeA,
for: .init(
target: targetB,
identity: routeA.identity,
remotePort: routeA.remotePort,
hostKeyPolicy: routeA.hostKeyPolicy)))
#expect(!RemoteTunnelManager._testCanReuse(
routeA,
for: .init(
target: routeA.target,
identity: "/tmp/id-b",
remotePort: routeA.remotePort,
hostKeyPolicy: routeA.hostKeyPolicy)))
#expect(!RemoteTunnelManager._testCanReuse(
routeA,
for: .init(
target: routeA.target,
identity: routeA.identity,
remotePort: 28789,
hostKeyPolicy: routeA.hostKeyPolicy)))
#expect(!RemoteTunnelManager._testCanReuse(
routeA,
for: .init(
target: routeA.target,
identity: routeA.identity,
remotePort: routeA.remotePort,
hostKeyPolicy: .openssh)))
}
@Test func `ssh restart backoff propagates cancellation`() async {
await #expect(throws: CancellationError.self) {
try await RemoteTunnelManager._testWaitForRestartBackoff(seconds: 2) { _ in
throw CancellationError()
}
}
}
@Test func `stale ssh waiter cannot replace current tunnel create`() throws {
let oldTarget = try #require(CommandResolver.parseSSHTarget("alice@gateway-a.example"))
let newTarget = try #require(CommandResolver.parseSSHTarget("alice@gateway-b.example"))
let oldConfiguration = RemotePortTunnel.Configuration(
target: oldTarget,
identity: "/tmp/id-a",
remotePort: 18789,
hostKeyPolicy: .strict)
let newConfiguration = RemotePortTunnel.Configuration(
target: newTarget,
identity: "/tmp/id-b",
remotePort: 18789,
hostKeyPolicy: .strict)
#expect(!RemoteTunnelManager._testIsCurrentConfiguration(
requested: oldConfiguration,
current: newConfiguration))
#expect(RemoteTunnelManager._testIsCurrentConfiguration(
requested: newConfiguration,
current: newConfiguration))
}
@Test func `normalize gateway url rejects public host ws`() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789")
#expect(url == nil)
}
@Test func `normalize gateway url rejects private ipv4 suffix host bypasses`() {
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://192.168.0.202.attacker.example:18789") == nil)
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://100.123.224.76.attacker.example:18789") == nil)
}
@Test func `normalize gateway url rejects ipv6 prefix hostname bypasses`() {
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://fcorp.example:18789") == nil)
#expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://fd-example.com:18789") == nil)
}
@Test func `normalize gateway url rejects prefix bypass loopback host`() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example")
#expect(url == nil)
}
@Test func `resolve tls fingerprint trims remote config value`() {
let root: [String: Any] = [
"gateway": [
"remote": [
"tlsFingerprint": " sha256:ABC123 ",
],
],
]
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: root) == "sha256:ABC123")
}
@Test func `resolve tls fingerprint ignores blank or non string values`() {
let blank: [String: Any] = [
"gateway": [
"remote": [
"tlsFingerprint": " ",
],
],
]
let nonString: [String: Any] = [
"gateway": [
"remote": [
"tlsFingerprint": 123,
],
],
]
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: blank) == nil)
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: nonString) == nil)
}
}