mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 14:34:32 +08:00
* style(swift): restore compact conditional bodies * fix(ios): remove redundant startup timeout await * chore(swift): sync native i18n inventory
1342 lines
58 KiB
Swift
1342 lines
58 KiB
Swift
import Foundation
|
|
import Network
|
|
import Observation
|
|
import OpenClawChatUI
|
|
import OpenClawKit
|
|
import SwiftUI
|
|
|
|
typealias GatewayTCPReachabilityProbe = @Sendable (String, Int, Double, String) async -> Bool
|
|
typealias GatewayServiceEndpointResolver = @Sendable (NWEndpoint) async -> (host: String, port: Int)?
|
|
typealias GatewayForceReconnectReset = @MainActor (NodeAppModel) async -> Void
|
|
typealias GatewayTLSFingerprintPersist = @Sendable (_ fingerprint: String, _ stableID: String) -> Bool
|
|
|
|
private enum GatewaySetupRouteProbeBudget {
|
|
static let tcpConnectTimeoutSeconds = 2.0
|
|
}
|
|
|
|
private func defaultGatewayTCPReachabilityProbe(
|
|
host: String,
|
|
port: Int,
|
|
timeoutSeconds: Double,
|
|
queueLabel: String) async -> Bool
|
|
{
|
|
await TCPProbe.probe(host: host, port: port, timeoutSeconds: timeoutSeconds, queueLabel: queueLabel)
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class GatewayConnectionController {
|
|
enum DiscoveredGatewayConnectionAvailability: Equatable {
|
|
case available
|
|
case secureTransportRequired
|
|
|
|
var canConnect: Bool {
|
|
self == .available
|
|
}
|
|
|
|
var actionTitle: String {
|
|
switch self {
|
|
case .available:
|
|
String(localized: "Connect")
|
|
case .secureTransportRequired:
|
|
String(localized: "TLS required")
|
|
}
|
|
}
|
|
|
|
var guidanceText: String? {
|
|
switch self {
|
|
case .available:
|
|
nil
|
|
case .secureTransportRequired:
|
|
String(localized: """
|
|
Enable Gateway TLS, or enter your Tailscale Serve HTTPS host in Manual Setup. \
|
|
Use Unencrypted only with a trusted private-LAN address.
|
|
""")
|
|
}
|
|
}
|
|
}
|
|
|
|
static func resolvedManualPort(host: String, port: Int) -> Int? {
|
|
if port > 0 {
|
|
return port <= 65535 ? port : nil
|
|
}
|
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !trimmedHost.isEmpty else { return nil }
|
|
if trimmedHost.hasSuffix(".ts.net") || trimmedHost.hasSuffix(".ts.net.") {
|
|
return 443
|
|
}
|
|
return 18789
|
|
}
|
|
|
|
struct TrustPrompt: Identifiable, Equatable {
|
|
let stableID: String
|
|
let gatewayName: String
|
|
let host: String
|
|
let port: Int
|
|
let fingerprintSha256: String
|
|
let isManual: Bool
|
|
|
|
var id: String {
|
|
self.stableID
|
|
}
|
|
}
|
|
|
|
struct AutoConnectSuppressionLease {
|
|
fileprivate let generation: UInt64
|
|
fileprivate let previousAutoReconnectEnabled: Bool
|
|
fileprivate let restoresAutoReconnect: Bool
|
|
fileprivate let suspendedConfig: GatewayConnectConfig?
|
|
}
|
|
|
|
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
|
private(set) var discoveryStatusText: String = "Idle"
|
|
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
|
|
private(set) var pendingTrustPrompt: TrustPrompt?
|
|
|
|
private let discovery = GatewayDiscoveryModel()
|
|
private let discoveryEnabled: Bool
|
|
private weak var appModel: NodeAppModel?
|
|
private var localNetworkAccessRequested: Bool
|
|
private var currentScenePhase: ScenePhase = .inactive
|
|
private var didAutoConnect = false
|
|
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
|
private var pendingTrustConnect: GatewayPendingTrustConnect?
|
|
private var trustProbeGeneration: UInt64 = 0
|
|
private var connectAttemptGeneration: UInt64 = 0
|
|
private var autoConnectSuppressionGeneration: UInt64?
|
|
private var autoConnectSuppressionBaseline: (
|
|
autoReconnectEnabled: Bool,
|
|
restoresAutoReconnect: Bool,
|
|
suspendedConfig: GatewayConnectConfig?)?
|
|
@ObservationIgnored private var pendingAutoConnectTask: Task<Void, Never>?
|
|
@ObservationIgnored private var pendingAutoConnectGeneration: UInt64?
|
|
@ObservationIgnored private var pendingAutoConnectSuppressionGeneration: UInt64?
|
|
@ObservationIgnored private var pendingForgetCleanups: [
|
|
String: (id: UUID, task: Task<Void, Never>)
|
|
] = [:]
|
|
private var pendingConnectionStableID: String?
|
|
private let tcpReachabilityProbe: GatewayTCPReachabilityProbe
|
|
private let tlsFingerprintProbe: GatewayTLSFingerprintProbeFunction
|
|
private let serviceEndpointResolver: GatewayServiceEndpointResolver?
|
|
private let forceReconnectReset: GatewayForceReconnectReset
|
|
private let persistTLSFingerprint: GatewayTLSFingerprintPersist
|
|
|
|
init(
|
|
appModel: NodeAppModel,
|
|
startDiscovery: Bool = true,
|
|
deferDiscoveryUntilLocalNetworkRequest: Bool = false,
|
|
tcpReachabilityProbe: @escaping GatewayTCPReachabilityProbe = defaultGatewayTCPReachabilityProbe,
|
|
tlsFingerprintProbe: @escaping GatewayTLSFingerprintProbeFunction = defaultGatewayTLSFingerprintProbe,
|
|
serviceEndpointResolver: GatewayServiceEndpointResolver? = nil,
|
|
forceReconnectReset: @escaping GatewayForceReconnectReset = { appModel in
|
|
await appModel.resetGatewaySessionsForForcedReconnect()
|
|
},
|
|
persistTLSFingerprint: @escaping GatewayTLSFingerprintPersist = { fingerprint, stableID in
|
|
GatewayTLSStore.replaceFingerprint(fingerprint, stableID: stableID)
|
|
})
|
|
{
|
|
self.discoveryEnabled = startDiscovery
|
|
self.appModel = appModel
|
|
self.localNetworkAccessRequested = !deferDiscoveryUntilLocalNetworkRequest
|
|
self.tcpReachabilityProbe = tcpReachabilityProbe
|
|
self.tlsFingerprintProbe = tlsFingerprintProbe
|
|
self.serviceEndpointResolver = serviceEndpointResolver
|
|
self.forceReconnectReset = forceReconnectReset
|
|
self.persistTLSFingerprint = persistTLSFingerprint
|
|
|
|
GatewaySettingsStore.bootstrapPersistence()
|
|
Self.migrateLegacyDeviceAuth()
|
|
let defaults = UserDefaults.standard
|
|
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs"))
|
|
|
|
self.updateFromDiscovery()
|
|
self.observeDiscovery()
|
|
|
|
if self.discoveryEnabled, self.localNetworkAccessRequested {
|
|
self.discovery.start()
|
|
}
|
|
}
|
|
|
|
func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) {
|
|
self.discovery.setDebugLoggingEnabled(enabled)
|
|
}
|
|
|
|
func selectReachableSetupLink(_ link: GatewayConnectDeepLink) async -> GatewayConnectDeepLink {
|
|
let endpoints = link.connectionEndpoints
|
|
guard endpoints.count > 1 else { return link }
|
|
// Probe before persisting: a setup code may carry LAN and Tailnet routes,
|
|
// but only the route reachable from the phone should become its saved endpoint.
|
|
self.requestLocalNetworkAccess(reason: "setup_route_probe")
|
|
for (index, endpoint) in endpoints.enumerated() {
|
|
let reachable = await self.tcpReachabilityProbe(
|
|
endpoint.host,
|
|
endpoint.port,
|
|
GatewaySetupRouteProbeBudget.tcpConnectTimeoutSeconds,
|
|
"ai.openclaw.gateway.setup-route-\(index)")
|
|
if reachable {
|
|
return link.selectingEndpoint(endpoint)
|
|
}
|
|
}
|
|
return link
|
|
}
|
|
|
|
func requestLocalNetworkAccess(reason: String, allowAutoReconnect: Bool = true) {
|
|
guard self.discoveryEnabled else {
|
|
self.discovery.stop()
|
|
self.updateFromDiscovery(allowAutoConnect: allowAutoReconnect)
|
|
return
|
|
}
|
|
|
|
self.localNetworkAccessRequested = true
|
|
GatewayDiagnostics.log("local network access requested reason=\(reason)")
|
|
|
|
guard self.currentScenePhase != .background else { return }
|
|
self.discovery.start()
|
|
self.updateFromDiscovery(allowAutoConnect: allowAutoReconnect)
|
|
guard allowAutoReconnect else { return }
|
|
self.attemptAutoReconnectIfNeeded()
|
|
}
|
|
|
|
func setScenePhase(_ phase: ScenePhase) {
|
|
self.currentScenePhase = phase
|
|
guard self.discoveryEnabled else {
|
|
self.discovery.stop()
|
|
return
|
|
}
|
|
guard self.localNetworkAccessRequested else { return }
|
|
|
|
switch phase {
|
|
case .background:
|
|
self.discovery.stop()
|
|
case .active, .inactive:
|
|
self.discovery.start()
|
|
self.attemptAutoReconnectIfNeeded()
|
|
@unknown default:
|
|
self.discovery.start()
|
|
self.attemptAutoReconnectIfNeeded()
|
|
}
|
|
}
|
|
|
|
func restartDiscovery() {
|
|
guard self.discoveryEnabled else {
|
|
self.discovery.stop()
|
|
self.updateFromDiscovery()
|
|
return
|
|
}
|
|
guard self.localNetworkAccessRequested else {
|
|
self.requestLocalNetworkAccess(reason: "restart_discovery")
|
|
return
|
|
}
|
|
|
|
self.discovery.stop()
|
|
self.didAutoConnect = false
|
|
self.discovery.start()
|
|
self.updateFromDiscovery()
|
|
}
|
|
|
|
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
|
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
|
await self.connectDiscoveredGateway(gateway)
|
|
}
|
|
|
|
func discoveredGatewayConnectionAvailability(
|
|
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> DiscoveredGatewayConnectionAvailability
|
|
{
|
|
if gateway.tlsEnabled || GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil {
|
|
return .available
|
|
}
|
|
return .secureTransportRequired
|
|
}
|
|
|
|
func preferredDiscoveredGateway() -> GatewayDiscoveryModel.DiscoveredGateway? {
|
|
self.gateways.first(where: {
|
|
self.discoveredGatewayConnectionAvailability($0).canConnect
|
|
}) ?? self.gateways.first
|
|
}
|
|
|
|
private func connectDiscoveredGateway(
|
|
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
|
forceReconnect: Bool = false) async -> String?
|
|
{
|
|
let availability = self.discoveredGatewayConnectionAvailability(gateway)
|
|
guard availability.canConnect else { return availability.guidanceText }
|
|
|
|
let connectAttempt = self.beginConnectAttempt()
|
|
self.pendingConnectionStableID = gateway.stableID
|
|
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
|
|
await self.waitForPendingForgetCleanup(stableID: gateway.stableID)
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
|
|
self.requestLocalNetworkAccess(reason: "connect_discovered_gateway", allowAutoReconnect: false)
|
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if instanceId.isEmpty {
|
|
return "Missing instanceId (node.instanceId). Try restarting the app."
|
|
}
|
|
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
|
let target = if let serviceEndpointResolver {
|
|
await serviceEndpointResolver(gateway.endpoint)
|
|
} else {
|
|
await self.resolveServiceEndpoint(gateway.endpoint)
|
|
}
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
|
|
guard let target else {
|
|
return "Failed to resolve the discovered gateway endpoint."
|
|
}
|
|
|
|
let stableID = gateway.stableID
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
// Discovery is a LAN operation; refuse unauthenticated plaintext connects.
|
|
let tlsRequired = true
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
|
|
if tlsRequired, stored == nil {
|
|
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
|
|
else { return "Failed to build TLS URL for trust verification." }
|
|
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
|
|
guard let probeResult = await self.probeTLSFingerprint(
|
|
host: target.host,
|
|
port: target.port,
|
|
url: url,
|
|
queueLabel: "gateway.tls.discovered")
|
|
else { return nil }
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
|
|
switch probeResult {
|
|
case let .fingerprint(fp):
|
|
self.pendingTrustConnect = GatewayPendingTrustConnect(
|
|
url: url,
|
|
stableID: stableID,
|
|
isManual: false,
|
|
authOverride: nil,
|
|
allowStoredDeviceAuth: true,
|
|
suppressionLease: connectAttempt.suppressionLease,
|
|
gatewayGeneration: connectAttempt.gatewayGeneration)
|
|
self.pendingTrustPrompt = TrustPrompt(
|
|
stableID: stableID,
|
|
gatewayName: gateway.name,
|
|
host: target.host,
|
|
port: target.port,
|
|
fingerprintSha256: fp,
|
|
isManual: false)
|
|
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
|
return nil
|
|
case let .failure(failure):
|
|
let message = self.tlsProbeFailureMessage(
|
|
failure,
|
|
host: target.host,
|
|
port: target.port)
|
|
self.appModel?.gatewayStatusText = message
|
|
return message
|
|
}
|
|
}
|
|
|
|
let tlsParams = stored.map { fp in
|
|
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
|
}
|
|
|
|
guard let url = self.buildGatewayURL(
|
|
host: target.host,
|
|
port: target.port,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return "Failed to build discovered gateway URL." }
|
|
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
|
|
stableID: stableID,
|
|
kind: .discovered,
|
|
name: gateway.name,
|
|
host: nil,
|
|
port: nil,
|
|
useTLS: true,
|
|
lastConnectedAtMs: nil)
|
|
guard self.persistActiveGateway(registryEntry) else {
|
|
return "Could not save the paired gateway."
|
|
}
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: credentials.token,
|
|
bootstrapToken: credentials.bootstrapToken,
|
|
password: credentials.password,
|
|
allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth,
|
|
forceReconnect: forceReconnect,
|
|
suppressionGeneration: connectAttempt.suppressionLease.generation,
|
|
expectedGeneration: connectAttempt.gatewayGeneration)
|
|
return nil
|
|
}
|
|
|
|
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
|
_ = await self.connectWithDiagnostics(gateway)
|
|
}
|
|
|
|
func connectManual(
|
|
host: String,
|
|
port: Int,
|
|
useTLS: Bool,
|
|
authOverride: ManualAuthOverride? = nil,
|
|
forceReconnect: Bool = false) async
|
|
{
|
|
let connectAttempt = self.beginConnectAttempt()
|
|
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
|
|
self.requestLocalNetworkAccess(reason: "connect_manual", allowAutoReconnect: false)
|
|
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
|
guard let resolvedPort = Self.resolvedManualPort(host: host, port: port)
|
|
else { return }
|
|
let stableID = self.manualStableID(host: host, port: resolvedPort)
|
|
self.pendingConnectionStableID = stableID
|
|
await self.waitForPendingForgetCleanup(stableID: stableID)
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
|
|
let instanceId = GatewaySettingsStore.currentInstanceID()
|
|
let storedCredentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
let token = authOverride.map(\.token) ?? storedCredentials.token
|
|
let bootstrapToken = authOverride.map(\.bootstrapToken) ?? storedCredentials.bootstrapToken
|
|
let password = authOverride.map(\.password) ?? storedCredentials.password
|
|
let suppressStoredDeviceAuth =
|
|
authOverride?.suppressStoredDeviceAuth ?? storedCredentials.suppressStoredDeviceAuth
|
|
let pendingAuthOverride = authOverride ?? (storedCredentials.hasCredentials
|
|
? ManualAuthOverride.explicit(
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
targetStableID: stableID,
|
|
suppressStoredDeviceAuth: suppressStoredDeviceAuth)
|
|
: nil)
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
if resolvedUseTLS, stored == nil {
|
|
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
|
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
|
|
guard let probeResult = await self.probeTLSFingerprint(
|
|
host: host,
|
|
port: resolvedPort,
|
|
url: url,
|
|
queueLabel: "gateway.tls.manual")
|
|
else { return }
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
|
|
switch probeResult {
|
|
case let .fingerprint(fp):
|
|
self.pendingTrustConnect = GatewayPendingTrustConnect(
|
|
url: url,
|
|
stableID: stableID,
|
|
isManual: true,
|
|
authOverride: pendingAuthOverride,
|
|
allowStoredDeviceAuth: !suppressStoredDeviceAuth,
|
|
suppressionLease: connectAttempt.suppressionLease,
|
|
gatewayGeneration: connectAttempt.gatewayGeneration)
|
|
self.pendingTrustPrompt = TrustPrompt(
|
|
stableID: stableID,
|
|
gatewayName: "\(host):\(resolvedPort)",
|
|
host: host,
|
|
port: resolvedPort,
|
|
fingerprintSha256: fp,
|
|
isManual: true)
|
|
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
|
return
|
|
case let .failure(failure):
|
|
self.appModel?.gatewayStatusText = self.tlsProbeFailureMessage(
|
|
failure,
|
|
host: host,
|
|
port: resolvedPort)
|
|
return
|
|
}
|
|
}
|
|
|
|
let tlsParams = stored.map { fp in
|
|
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
|
}
|
|
guard let url = self.buildGatewayURL(
|
|
host: host,
|
|
port: resolvedPort,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return }
|
|
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
|
|
stableID: stableID,
|
|
kind: .manual,
|
|
name: "\(host):\(resolvedPort)",
|
|
host: host,
|
|
port: resolvedPort,
|
|
useTLS: resolvedUseTLS && tlsParams != nil,
|
|
lastConnectedAtMs: nil)
|
|
guard self.persistActiveGateway(registryEntry) else { return }
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
allowStoredDeviceAuth: !suppressStoredDeviceAuth,
|
|
forceReconnect: forceReconnect,
|
|
suppressionGeneration: connectAttempt.suppressionLease.generation,
|
|
expectedGeneration: connectAttempt.gatewayGeneration)
|
|
}
|
|
|
|
func connectActiveGateway() async {
|
|
self.requestLocalNetworkAccess(reason: "connect_active_gateway", allowAutoReconnect: false)
|
|
guard let active = GatewaySettingsStore.activeGatewayEntry() else { return }
|
|
switch active.kind {
|
|
case .manual:
|
|
guard let host = active.host, let port = active.port else { return }
|
|
await self.connectManual(host: host, port: port, useTLS: active.useTLS, forceReconnect: true)
|
|
case .discovered:
|
|
if let gateway = self.gateways.first(where: { $0.stableID == active.stableID }) {
|
|
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
|
|
return
|
|
}
|
|
guard let fallback = self.mostRecentlyConnectedManualGateway() else { return }
|
|
guard let host = fallback.host, let port = fallback.port else { return }
|
|
await self.connectManual(host: host, port: port, useTLS: fallback.useTLS, forceReconnect: true)
|
|
}
|
|
}
|
|
|
|
/// Returns `nil` after initiating a switch, or a user-facing discovery failure.
|
|
func switchToGateway(stableID: String) async -> String? {
|
|
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let entry = GatewaySettingsStore.loadGatewayRegistry().entries.first(where: {
|
|
$0.stableID == stableID
|
|
}) else {
|
|
return "This paired gateway is no longer available."
|
|
}
|
|
switch entry.kind {
|
|
case .manual:
|
|
guard let host = entry.host, let port = entry.port else {
|
|
return "This paired gateway has an invalid saved endpoint."
|
|
}
|
|
// Switching intentionally persists the user's selection at initiation, matching connect flows.
|
|
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
|
|
return "Could not save the active gateway selection."
|
|
}
|
|
await self.connectManual(
|
|
host: host,
|
|
port: port,
|
|
useTLS: entry.useTLS,
|
|
forceReconnect: true)
|
|
return nil
|
|
case .discovered:
|
|
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else {
|
|
return "\(entry.name) is not currently discoverable on this network."
|
|
}
|
|
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
|
|
return "Could not save the active gateway selection."
|
|
}
|
|
return await self.connectDiscoveredGateway(gateway, forceReconnect: true)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func forgetGateway(stableID: String) -> Bool {
|
|
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !stableID.isEmpty else { return false }
|
|
if self.pendingForgetCleanups[stableID] != nil {
|
|
return true
|
|
}
|
|
guard GatewaySettingsStore.removeGatewayRegistryEntry(stableID: stableID) else {
|
|
return false
|
|
}
|
|
if self.pendingConnectionStableID == stableID {
|
|
let cancellationLease = self.cancelPendingConnectionAttempts()
|
|
self.releaseAutoConnectSuppression(after: cancellationLease)
|
|
}
|
|
let wasConnected = self.appModel?.activeGatewayConnectConfig?.effectiveStableID == stableID ||
|
|
self.appModel?.connectedGatewayID == stableID
|
|
let shouldDisconnect = wasConnected
|
|
if shouldDisconnect {
|
|
let hasDifferentPendingTarget = self.pendingConnectionStableID.map { $0 != stableID } ?? false
|
|
self.appModel?.disconnectForgottenGateway(
|
|
preservingPendingConnectAttempt: hasDifferentPendingTarget)
|
|
}
|
|
let instanceID = GatewaySettingsStore.currentInstanceID()
|
|
self.clearLegacyManualGatewayDefaults(matching: stableID)
|
|
GatewaySettingsStore.clearLegacyGatewaySelectors(stableID: stableID)
|
|
GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID, stableID: stableID)
|
|
_ = GatewaySettingsStore.clearGatewayCustomHeaders(gatewayStableID: stableID)
|
|
_ = GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: stableID, clientId: nil)
|
|
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: nil)
|
|
let shareRelayGatewayID = ShareGatewayRelaySettings.loadConfig()?.gatewayStableID?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if shareRelayGatewayID == stableID {
|
|
ShareGatewayRelaySettings.clearConfig()
|
|
}
|
|
|
|
Self.clearDeviceAuthTokens(gatewayID: stableID)
|
|
let cleanupID = UUID()
|
|
let cleanupTask = Task { @MainActor [weak appModel] in
|
|
if let appModel {
|
|
if shouldDisconnect {
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
// A handshake racing teardown can persist a token after the first cleanup.
|
|
Self.clearDeviceAuthTokens(gatewayID: stableID)
|
|
}
|
|
await appModel.purgeChatTranscriptCache(gatewayID: stableID)
|
|
} else if let databaseURL = NodeAppModel.chatTranscriptCacheDatabaseURL(gatewayID: stableID) {
|
|
OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL)
|
|
}
|
|
}
|
|
self.pendingForgetCleanups[stableID] = (cleanupID, cleanupTask)
|
|
Task { @MainActor [weak self] in
|
|
await cleanupTask.value
|
|
guard self?.pendingForgetCleanups[stableID]?.id == cleanupID else { return }
|
|
self?.pendingForgetCleanups[stableID] = nil
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func waitForPendingForgetCleanup(stableID: String) async {
|
|
guard let pending = self.pendingForgetCleanups[stableID] else { return }
|
|
await pending.task.value
|
|
if self.pendingForgetCleanups[stableID]?.id == pending.id {
|
|
self.pendingForgetCleanups[stableID] = nil
|
|
}
|
|
}
|
|
|
|
private func persistActiveGateway(_ entry: GatewaySettingsStore.GatewayRegistryEntry) -> Bool {
|
|
guard GatewaySettingsStore.upsertGatewayRegistryEntry(entry, activate: true) else {
|
|
self.appModel?.gatewayStatusText = "Could not save paired gateway"
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private static func clearDeviceAuthTokens(gatewayID: String) {
|
|
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
|
DeviceAuthStore.clearToken(deviceId: primaryIdentity.deviceId, role: "node", gatewayID: gatewayID)
|
|
DeviceAuthStore.clearToken(deviceId: primaryIdentity.deviceId, role: "operator", gatewayID: gatewayID)
|
|
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
|
DeviceAuthStore.clearToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: gatewayID,
|
|
profile: .shareExtension)
|
|
DeviceAuthStore.clearToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "operator",
|
|
gatewayID: gatewayID,
|
|
profile: .shareExtension)
|
|
}
|
|
|
|
private func clearLegacyManualGatewayDefaults(matching stableID: String) {
|
|
let defaults = UserDefaults.standard
|
|
let host = defaults.string(forKey: "gateway.manual.host")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let port = Self.resolvedManualPort(
|
|
host: host,
|
|
port: defaults.integer(forKey: "gateway.manual.port"))
|
|
guard !host.isEmpty, let port, self.manualStableID(host: host, port: port) == stableID else { return }
|
|
defaults.set(false, forKey: "gateway.manual.enabled")
|
|
defaults.removeObject(forKey: "gateway.manual.host")
|
|
defaults.removeObject(forKey: "gateway.manual.port")
|
|
defaults.removeObject(forKey: "gateway.manual.tls")
|
|
}
|
|
|
|
/// Rebuild connect options from current local settings (caps/commands/permissions)
|
|
/// and re-apply the active gateway config so capability changes take effect immediately.
|
|
func refreshActiveGatewayRegistrationFromSettings() {
|
|
Task { [weak self] in
|
|
await self?.refreshActiveGatewayRegistrationFromSettingsAsync()
|
|
}
|
|
}
|
|
|
|
private func refreshActiveGatewayRegistrationFromSettingsAsync() async {
|
|
guard let appModel else { return }
|
|
guard let cfg = appModel.activeGatewayConnectConfig else { return }
|
|
guard appModel.gatewayAutoReconnectEnabled else { return }
|
|
let generation = appModel.gatewayConnectGeneration
|
|
|
|
let nodeOptions = await self.makeConnectOptions(
|
|
stableID: cfg.stableID,
|
|
deviceAuthGatewayID: cfg.nodeOptions.deviceAuthGatewayID,
|
|
allowStoredDeviceAuth: cfg.nodeOptions.allowStoredDeviceAuth)
|
|
let refreshedConfig = GatewayConnectConfig(
|
|
url: cfg.url,
|
|
stableID: cfg.stableID,
|
|
tls: cfg.tls,
|
|
token: cfg.token,
|
|
bootstrapToken: cfg.bootstrapToken,
|
|
password: cfg.password,
|
|
nodeOptions: nodeOptions)
|
|
appModel.applyGatewayConnectConfig(refreshedConfig, expectedGeneration: generation)
|
|
}
|
|
|
|
func clearPendingTrustPrompt() {
|
|
// Invalidate an in-flight probe so its late result cannot restore a stale prompt.
|
|
self.trustProbeGeneration &+= 1
|
|
self.pendingTrustPrompt = nil
|
|
self.pendingTrustConnect = nil
|
|
self.pendingConnectionStableID = nil
|
|
}
|
|
|
|
@discardableResult
|
|
func cancelPendingConnectionAttempts(
|
|
suspendCurrentGateway: Bool = false) -> AutoConnectSuppressionLease
|
|
{
|
|
let lease = self.beginAutoConnectSuppression(restoresAutoReconnect: suspendCurrentGateway)
|
|
_ = self.reserveGatewayConnectAttempt()
|
|
if suspendCurrentGateway {
|
|
self.appModel?.suspendGatewayForTargetReview()
|
|
}
|
|
return lease
|
|
}
|
|
|
|
private func beginAutoConnectSuppression(restoresAutoReconnect: Bool) -> AutoConnectSuppressionLease {
|
|
let baseline = if self.autoConnectSuppressionGeneration != nil,
|
|
let baseline = self.autoConnectSuppressionBaseline
|
|
{
|
|
(
|
|
autoReconnectEnabled: baseline.autoReconnectEnabled,
|
|
restoresAutoReconnect: baseline.restoresAutoReconnect || restoresAutoReconnect,
|
|
suspendedConfig: baseline.suspendedConfig ??
|
|
(restoresAutoReconnect ? self.appModel?.activeGatewayConnectConfig : nil))
|
|
} else {
|
|
(
|
|
autoReconnectEnabled: self.appModel?.gatewayAutoReconnectEnabled ?? false,
|
|
restoresAutoReconnect: restoresAutoReconnect,
|
|
suspendedConfig: restoresAutoReconnect ? self.appModel?.activeGatewayConnectConfig : nil)
|
|
}
|
|
self.connectAttemptGeneration &+= 1
|
|
self.autoConnectSuppressionGeneration = self.connectAttemptGeneration
|
|
self.autoConnectSuppressionBaseline = baseline
|
|
self.clearPendingTrustPrompt()
|
|
return AutoConnectSuppressionLease(
|
|
generation: self.connectAttemptGeneration,
|
|
previousAutoReconnectEnabled: baseline.autoReconnectEnabled,
|
|
restoresAutoReconnect: baseline.restoresAutoReconnect,
|
|
suspendedConfig: baseline.suspendedConfig)
|
|
}
|
|
|
|
func resumeAutoConnect(after lease: AutoConnectSuppressionLease) {
|
|
// A dismissed older target must not release suppression owned by its replacement.
|
|
guard self.autoConnectSuppressionGeneration == lease.generation else { return }
|
|
self.clearAutoConnectSuppression(generation: lease.generation)
|
|
if lease.restoresAutoReconnect {
|
|
let currentPreference = UserDefaults.standard.bool(forKey: "gateway.autoconnect")
|
|
if lease.previousAutoReconnectEnabled,
|
|
currentPreference,
|
|
let suspendedConfig = lease.suspendedConfig
|
|
{
|
|
self.appModel?.resumeGatewayAfterTargetReview(suspendedConfig)
|
|
return
|
|
}
|
|
self.appModel?.gatewayAutoReconnectEnabled = lease.previousAutoReconnectEnabled && currentPreference
|
|
}
|
|
self.attemptAutoReconnectIfNeeded()
|
|
}
|
|
|
|
func releaseAutoConnectSuppression(after lease: AutoConnectSuppressionLease) {
|
|
self.clearAutoConnectSuppression(generation: lease.generation)
|
|
}
|
|
|
|
private func clearAutoConnectSuppression(generation: UInt64) {
|
|
guard self.autoConnectSuppressionGeneration == generation else { return }
|
|
self.autoConnectSuppressionGeneration = nil
|
|
self.autoConnectSuppressionBaseline = nil
|
|
}
|
|
|
|
func acceptPendingTrustPrompt() async {
|
|
guard let pending = self.pendingTrustConnect,
|
|
let prompt = self.pendingTrustPrompt,
|
|
pending.stableID == prompt.stableID
|
|
else { return }
|
|
|
|
guard self.persistTLSFingerprint(prompt.fingerprintSha256, pending.stableID) else {
|
|
self.appModel?.gatewayStatusText = "Could not save gateway certificate"
|
|
return
|
|
}
|
|
|
|
let instanceId = GatewaySettingsStore.currentInstanceID()
|
|
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
|
|
stableID: pending.stableID,
|
|
kind: pending.isManual ? .manual : .discovered,
|
|
name: prompt.gatewayName,
|
|
host: pending.isManual ? prompt.host : nil,
|
|
port: pending.isManual ? prompt.port : nil,
|
|
useTLS: true,
|
|
lastConnectedAtMs: nil)
|
|
guard self.persistActiveGateway(registryEntry) else {
|
|
_ = GatewayTLSStore.clearFingerprint(stableID: pending.stableID)
|
|
return
|
|
}
|
|
self.clearPendingTrustPrompt()
|
|
let storedCredentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: pending.stableID)
|
|
let token = pending.authOverride.map(\.token) ?? storedCredentials.token
|
|
let bootstrapToken = pending.authOverride.map(\.bootstrapToken) ?? storedCredentials.bootstrapToken
|
|
let password = pending.authOverride.map(\.password) ?? storedCredentials.password
|
|
let suppressStoredDeviceAuth =
|
|
pending.authOverride?.suppressStoredDeviceAuth ?? storedCredentials.suppressStoredDeviceAuth
|
|
let tlsParams = GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: prompt.fingerprintSha256,
|
|
allowTOFU: false,
|
|
storeKey: pending.stableID)
|
|
|
|
self.didAutoConnect = true
|
|
let didStart = self.startAutoConnect(
|
|
url: pending.url,
|
|
gatewayStableID: pending.stableID,
|
|
tls: tlsParams,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
allowStoredDeviceAuth: pending.allowStoredDeviceAuth && !suppressStoredDeviceAuth,
|
|
suppressionGeneration: pending.suppressionLease.generation,
|
|
expectedGeneration: pending.gatewayGeneration)
|
|
if !didStart {
|
|
self.clearAutoConnectSuppression(generation: pending.suppressionLease.generation)
|
|
}
|
|
}
|
|
|
|
func declinePendingTrustPrompt() {
|
|
let lease = self.pendingTrustConnect?.suppressionLease
|
|
self.clearPendingTrustPrompt()
|
|
self.appModel?.gatewayStatusText = "Offline"
|
|
if let lease {
|
|
self.resumeAutoConnect(after: lease)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem) async -> Bool {
|
|
guard problem.canTrustRotatedCertificate,
|
|
let stableID = problem.tlsStoreKey,
|
|
let fingerprint = problem.tlsObservedFingerprint
|
|
else {
|
|
self.appModel?.gatewayStatusText = "Certificate review required"
|
|
return false
|
|
}
|
|
|
|
guard self.persistTLSFingerprint(fingerprint, stableID) else {
|
|
self.appModel?.gatewayStatusText = "Could not update gateway certificate"
|
|
return false
|
|
}
|
|
|
|
GatewayDiagnostics.log(
|
|
"gateway tls pin replaced stableID=\(stableID) "
|
|
+ "old=\(problem.tlsExpectedFingerprint ?? "unknown") new=\(fingerprint)")
|
|
self.appModel?.gatewayStatusText = "Gateway certificate updated. Reconnecting…"
|
|
if let appModel = self.appModel, let cfg = appModel.activeGatewayConnectConfig {
|
|
let currentTLS = cfg.tls
|
|
let refreshedTLS = GatewayTLSParams(
|
|
required: currentTLS?.required ?? true,
|
|
expectedFingerprint: fingerprint,
|
|
allowTOFU: currentTLS?.allowTOFU ?? false,
|
|
storeKey: currentTLS?.storeKey ?? stableID)
|
|
let refreshedConfig = GatewayConnectConfig(
|
|
url: cfg.url,
|
|
stableID: cfg.stableID,
|
|
tls: refreshedTLS,
|
|
token: cfg.token,
|
|
bootstrapToken: cfg.bootstrapToken,
|
|
password: cfg.password,
|
|
nodeOptions: cfg.nodeOptions)
|
|
appModel.applyGatewayConnectConfig(refreshedConfig)
|
|
} else {
|
|
await self.connectActiveGateway()
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
extension GatewayConnectionController {
|
|
private func updateFromDiscovery(allowAutoConnect: Bool = true) {
|
|
let newGateways = self.discovery.gateways
|
|
self.gateways = newGateways
|
|
self.discoveryStatusText = self.discovery.statusText
|
|
self.discoveryDebugLog = self.discovery.debugLog
|
|
self.updateLastDiscoveredGateway(from: newGateways)
|
|
if allowAutoConnect {
|
|
self.maybeAutoConnect()
|
|
}
|
|
}
|
|
|
|
private func observeDiscovery() {
|
|
withObservationTracking {
|
|
_ = self.discovery.gateways
|
|
_ = self.discovery.statusText
|
|
_ = self.discovery.debugLog
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.updateFromDiscovery()
|
|
self.observeDiscovery()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func maybeAutoConnect() {
|
|
guard self.autoConnectSuppressionGeneration == nil else { return }
|
|
guard !self.didAutoConnect else { return }
|
|
guard let appModel = self.appModel else { return }
|
|
guard appModel.gatewayServerName == nil else { return }
|
|
|
|
let defaults = UserDefaults.standard
|
|
guard defaults.bool(forKey: "gateway.autoconnect") else { return }
|
|
|
|
let instanceId = defaults.string(forKey: "node.instanceId")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !instanceId.isEmpty else { return }
|
|
|
|
if let active = GatewaySettingsStore.activeGatewayEntry() {
|
|
if self.startActiveGatewayAutoConnect(active, instanceId: instanceId) {
|
|
return
|
|
}
|
|
if active.kind == .discovered,
|
|
let target = self.gateways.first(where: { $0.stableID == active.stableID }),
|
|
GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil
|
|
{
|
|
self.didAutoConnect = true
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
_ = await self.connectDiscoveredGateway(target)
|
|
}
|
|
return
|
|
}
|
|
if active.kind == .discovered,
|
|
let fallback = self.mostRecentlyConnectedManualGateway(),
|
|
self.startActiveGatewayAutoConnect(fallback, instanceId: instanceId)
|
|
{
|
|
_ = GatewaySettingsStore.setActiveGateway(stableID: fallback.stableID)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
if defaults.bool(forKey: "gateway.manual.enabled") {
|
|
self.startConfiguredManualAutoConnect(defaults: defaults, instanceId: instanceId)
|
|
return
|
|
}
|
|
|
|
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
|
if let targetStableID = candidates.first(where: { id in
|
|
self.gateways.contains(where: { $0.stableID == id })
|
|
}) {
|
|
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
|
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
|
guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return }
|
|
|
|
self.didAutoConnect = true
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
_ = await self.connectDiscoveredGateway(target)
|
|
}
|
|
return
|
|
}
|
|
|
|
if self.gateways.count == 1, let gateway = self.gateways.first {
|
|
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
|
guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return }
|
|
|
|
self.didAutoConnect = true
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
_ = await self.connectDiscoveredGateway(gateway)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
private func startConfiguredManualAutoConnect(defaults: UserDefaults, instanceId: String) {
|
|
let host = defaults.string(forKey: "gateway.manual.host")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !host.isEmpty else { return }
|
|
|
|
let configuredPort = defaults.integer(forKey: "gateway.manual.port")
|
|
let configuredTLS = defaults.bool(forKey: "gateway.manual.tls")
|
|
let useTLS = self.resolveManualUseTLS(host: host, useTLS: configuredTLS)
|
|
guard let port = Self.resolvedManualPort(host: host, port: configuredPort) else { return }
|
|
|
|
let stableID = self.manualStableID(host: host, port: port)
|
|
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
|
// Manual TLS auto-connect cannot present first-use trust UI, so it requires an existing pin.
|
|
guard !useTLS || tlsParams?.expectedFingerprint != nil else { return }
|
|
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
|
else { return }
|
|
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: credentials.token,
|
|
bootstrapToken: credentials.bootstrapToken,
|
|
password: credentials.password,
|
|
allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth)
|
|
}
|
|
|
|
private func startActiveGatewayAutoConnect(
|
|
_ active: GatewaySettingsStore.GatewayRegistryEntry,
|
|
instanceId: String) -> Bool
|
|
{
|
|
switch active.kind {
|
|
case .manual:
|
|
guard let host = active.host, let port = active.port else { return false }
|
|
let stableID = active.stableID
|
|
let useTLS = active.useTLS
|
|
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
|
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: resolvedUseTLS)
|
|
guard !resolvedUseTLS || tlsParams?.expectedFingerprint != nil else { return false }
|
|
guard let url = self.buildGatewayURL(
|
|
host: host,
|
|
port: port,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return false }
|
|
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: credentials.token,
|
|
bootstrapToken: credentials.bootstrapToken,
|
|
password: credentials.password,
|
|
allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth)
|
|
return true
|
|
case .discovered:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func attemptAutoReconnectIfNeeded() {
|
|
guard let appModel = self.appModel else { return }
|
|
guard appModel.gatewayAutoReconnectEnabled else { return }
|
|
guard self.autoConnectSuppressionGeneration == nil else { return }
|
|
// Avoid starting duplicate connect loops while a prior config is active.
|
|
guard appModel.activeGatewayConnectConfig == nil else { return }
|
|
guard UserDefaults.standard.bool(forKey: "gateway.autoconnect") else { return }
|
|
self.didAutoConnect = false
|
|
self.maybeAutoConnect()
|
|
}
|
|
|
|
private func mostRecentlyConnectedManualGateway() -> GatewaySettingsStore.GatewayRegistryEntry? {
|
|
GatewaySettingsStore.loadGatewayRegistry().entries
|
|
.filter { $0.kind == .manual }
|
|
.max { lhs, rhs in
|
|
let lhsConnected = lhs.lastConnectedAtMs ?? Int.min
|
|
let rhsConnected = rhs.lastConnectedAtMs ?? Int.min
|
|
if lhsConnected != rhsConnected { return lhsConnected < rhsConnected }
|
|
return lhs.stableID > rhs.stableID
|
|
}
|
|
}
|
|
|
|
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
|
let defaults = UserDefaults.standard
|
|
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
|
|
guard preferred.isEmpty, existingLast.isEmpty else { return }
|
|
guard let first = gateways.first else { return }
|
|
|
|
defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID")
|
|
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID)
|
|
}
|
|
|
|
@discardableResult
|
|
private func startAutoConnect(
|
|
url: URL,
|
|
gatewayStableID: String,
|
|
tls: GatewayTLSParams?,
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
allowStoredDeviceAuth: Bool = true,
|
|
forceReconnect: Bool = false,
|
|
suppressionGeneration: UInt64? = nil,
|
|
expectedGeneration: UInt64? = nil) -> Bool
|
|
{
|
|
guard let appModel else { return false }
|
|
if let expectedGeneration {
|
|
guard expectedGeneration == appModel.gatewayConnectGeneration else { return false }
|
|
}
|
|
let previousTask = self.pendingAutoConnectTask
|
|
previousTask?.cancel()
|
|
// Advancing again at handoff rejects work that captured the reservation generation while
|
|
// endpoint resolution or trust verification was suspended.
|
|
let generation = appModel.beginGatewayConnectAttempt()
|
|
self.pendingAutoConnectGeneration = generation
|
|
self.pendingConnectionStableID = gatewayStableID
|
|
// An explicit target owns suppression until its queued handoff exits. Otherwise a
|
|
// foreground reconnect can replace it while reset or permission work is suspended.
|
|
self.pendingAutoConnectSuppressionGeneration = suppressionGeneration
|
|
appModel.gatewayStatusText = "Connecting…"
|
|
let task = Task { [weak self, weak appModel] in
|
|
guard let self, let appModel else { return }
|
|
defer {
|
|
if self.pendingAutoConnectGeneration == generation {
|
|
self.pendingAutoConnectTask = nil
|
|
self.pendingAutoConnectGeneration = nil
|
|
self.pendingAutoConnectSuppressionGeneration = nil
|
|
if self.pendingConnectionStableID == gatewayStableID {
|
|
self.pendingConnectionStableID = nil
|
|
}
|
|
}
|
|
if let suppressionGeneration,
|
|
self.autoConnectSuppressionGeneration == suppressionGeneration
|
|
{
|
|
self.clearAutoConnectSuppression(generation: suppressionGeneration)
|
|
}
|
|
}
|
|
await previousTask?.value
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return }
|
|
if forceReconnect {
|
|
await self.forceReconnectReset(appModel)
|
|
guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return }
|
|
}
|
|
let nodeOptions = await self.makeConnectOptions(
|
|
stableID: gatewayStableID,
|
|
deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayStableID),
|
|
allowStoredDeviceAuth: allowStoredDeviceAuth)
|
|
// Permission reads above can suspend long enough for a model-owned reconnect reset
|
|
// to start, so close the reset barrier again immediately before the synchronous apply.
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return }
|
|
let cfg = GatewayConnectConfig(
|
|
url: url,
|
|
stableID: gatewayStableID,
|
|
tls: tls,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
nodeOptions: nodeOptions)
|
|
appModel.applyGatewayConnectConfig(
|
|
cfg,
|
|
forceReconnect: forceReconnect,
|
|
expectedGeneration: generation)
|
|
}
|
|
self.pendingAutoConnectTask = task
|
|
return true
|
|
}
|
|
|
|
private func resolveDiscoveredTLSParams(
|
|
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
|
{
|
|
let stableID = gateway.stableID
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
|
|
// Never let unauthenticated discovery (TXT) override a stored pin.
|
|
if let stored {
|
|
return GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: stored,
|
|
allowTOFU: false,
|
|
storeKey: stableID)
|
|
}
|
|
|
|
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil {
|
|
return GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: nil,
|
|
allowTOFU: false,
|
|
storeKey: stableID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func resolveManualTLSParams(
|
|
stableID: String,
|
|
tlsEnabled: Bool) -> GatewayTLSParams?
|
|
{
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
if tlsEnabled || stored != nil {
|
|
return GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: stored,
|
|
allowTOFU: false,
|
|
storeKey: stableID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func probeTLSFingerprint(
|
|
host: String,
|
|
port: Int,
|
|
url: URL,
|
|
queueLabel: String) async -> GatewayTLSFingerprintProbeResult?
|
|
{
|
|
self.trustProbeGeneration &+= 1
|
|
let generation = self.trustProbeGeneration
|
|
self.pendingTrustConnect = nil
|
|
self.pendingTrustPrompt = nil
|
|
let reachable = await self.tcpReachabilityProbe(
|
|
host,
|
|
port,
|
|
GatewayTLSFingerprintProbeBudget.tcpConnectTimeoutSeconds,
|
|
queueLabel)
|
|
guard self.trustProbeGeneration == generation else { return nil }
|
|
guard reachable else {
|
|
return .failure(.endpointUnreachable)
|
|
}
|
|
let result = await self.tlsFingerprintProbe(url)
|
|
guard self.trustProbeGeneration == generation else { return nil }
|
|
return result
|
|
}
|
|
|
|
private func beginConnectAttempt()
|
|
-> (suppressionLease: AutoConnectSuppressionLease, gatewayGeneration: UInt64?)
|
|
{
|
|
let suppressionLease = self.beginAutoConnectSuppression(restoresAutoReconnect: false)
|
|
// Allocate both tokens before any resolution or trust work. A new explicit target must
|
|
// invalidate queued config construction from the previous target immediately.
|
|
let gatewayGeneration = self.reserveGatewayConnectAttempt()
|
|
return (suppressionLease, gatewayGeneration)
|
|
}
|
|
|
|
private func reserveGatewayConnectAttempt() -> UInt64? {
|
|
let previousTask = self.pendingAutoConnectTask
|
|
previousTask?.cancel()
|
|
self.pendingConnectionStableID = nil
|
|
guard let appModel else { return nil }
|
|
let generation = appModel.beginGatewayConnectAttempt()
|
|
let activeConfig = appModel.activeGatewayConnectConfig
|
|
let shouldRestoreActiveConfig = appModel.gatewayAutoReconnectEnabled &&
|
|
!appModel.gatewayPairingPaused &&
|
|
appModel.lastGatewayProblem?.pauseReconnect != true &&
|
|
(previousTask != nil || appModel.hasGatewaySessionResetInFlight)
|
|
self.pendingAutoConnectSuppressionGeneration = nil
|
|
self.pendingAutoConnectGeneration = generation
|
|
// The barrier owns any superseded teardown until it finishes. If the replacement never
|
|
// reaches handoff, restore the still-current route after that teardown completes.
|
|
let barrier = Task { [weak self, weak appModel] in
|
|
guard let self, let appModel else { return }
|
|
defer {
|
|
if self.pendingAutoConnectGeneration == generation {
|
|
self.pendingAutoConnectTask = nil
|
|
self.pendingAutoConnectGeneration = nil
|
|
}
|
|
}
|
|
await previousTask?.value
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
guard !Task.isCancelled,
|
|
generation == appModel.gatewayConnectGeneration,
|
|
shouldRestoreActiveConfig,
|
|
let activeConfig,
|
|
appModel.gatewayAutoReconnectEnabled,
|
|
!appModel.gatewayPairingPaused,
|
|
appModel.lastGatewayProblem?.pauseReconnect != true,
|
|
appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: activeConfig) == true
|
|
else { return }
|
|
appModel.applyGatewayConnectConfig(activeConfig, expectedGeneration: generation)
|
|
}
|
|
self.pendingAutoConnectTask = barrier
|
|
return generation
|
|
}
|
|
|
|
private func finishConnectAttempt(_ lease: AutoConnectSuppressionLease) {
|
|
guard self.connectAttemptGeneration == lease.generation else { return }
|
|
guard self.pendingTrustPrompt == nil else { return }
|
|
guard self.pendingAutoConnectSuppressionGeneration != lease.generation else { return }
|
|
self.pendingConnectionStableID = nil
|
|
if lease.restoresAutoReconnect {
|
|
self.resumeAutoConnect(after: lease)
|
|
} else {
|
|
self.releaseAutoConnectSuppression(after: lease)
|
|
}
|
|
}
|
|
|
|
private func tlsProbeFailureMessage(
|
|
_ failure: GatewayTLSFingerprintProbeFailure,
|
|
host: String,
|
|
port: Int) -> String
|
|
{
|
|
switch failure {
|
|
case .endpointUnreachable:
|
|
if host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")).hasSuffix(".ts.net") {
|
|
String(localized: """
|
|
Can't reach gateway at \(host):\(port). \
|
|
Verify Tailscale Serve is enabled and publishes this Gateway.
|
|
""")
|
|
} else {
|
|
String(localized: "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.")
|
|
}
|
|
case .tlsHandshakeTimeout:
|
|
"TLS fingerprint verification timed out for \(host):\(port). "
|
|
+ "Secure endpoint was reached, but TLS did not finish in time."
|
|
case .tlsUnavailable:
|
|
"No secure gateway endpoint was detected at \(host):\(port). "
|
|
+ "Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address "
|
|
+ "with Unencrypted selected."
|
|
case .certificateUnavailable:
|
|
"Could not read the TLS certificate from \(host):\(port)."
|
|
}
|
|
}
|
|
|
|
private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
|
guard case let .service(name, type, domain, _) = endpoint else { return nil }
|
|
let key = "\(domain)|\(type)|\(name)"
|
|
return await withCheckedContinuation { continuation in
|
|
let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in
|
|
Task { @MainActor in
|
|
self?.pendingServiceResolvers[key] = nil
|
|
continuation.resume(returning: result)
|
|
}
|
|
}
|
|
self.pendingServiceResolvers[key] = resolver
|
|
resolver.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct GatewayPendingTrustConnect {
|
|
let url: URL
|
|
let stableID: String
|
|
let isManual: Bool
|
|
let authOverride: GatewayConnectionController.ManualAuthOverride?
|
|
let allowStoredDeviceAuth: Bool
|
|
let suppressionLease: GatewayConnectionController.AutoConnectSuppressionLease
|
|
let gatewayGeneration: UInt64?
|
|
}
|
|
|
|
#if DEBUG
|
|
extension GatewayConnectionController {
|
|
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
|
self.gateways = gateways
|
|
}
|
|
|
|
func _test_triggerAutoConnect() {
|
|
self.maybeAutoConnect()
|
|
}
|
|
|
|
func _test_triggerAutoReconnect() {
|
|
self.attemptAutoReconnectIfNeeded()
|
|
}
|
|
|
|
func _test_didAutoConnect() -> Bool {
|
|
self.didAutoConnect
|
|
}
|
|
|
|
func _test_isAutoConnectSuppressed() -> Bool {
|
|
self.autoConnectSuppressionGeneration != nil
|
|
}
|
|
|
|
func _test_resolveDiscoveredTLSParams(
|
|
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
|
{
|
|
self.resolveDiscoveredTLSParams(gateway: gateway)
|
|
}
|
|
|
|
func _test_resolveManualPort(host: String, port: Int, useTLS _: Bool) -> Int? {
|
|
Self.resolvedManualPort(host: host, port: port)
|
|
}
|
|
}
|
|
#endif
|