GatewayChannel now sends both 'token' and 'password' fields in the auth
payload to support both authentication modes. Gateway checks the field
matching its auth.mode configuration ('token' or 'password').
Also adds config file password fallback for remote mode, allowing
gateway password to be configured in ~/.clawdis/clawdis.json without
requiring environment variables.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
167 lines
6.8 KiB
Swift
167 lines
6.8 KiB
Swift
import Foundation
|
|
import OSLog
|
|
|
|
enum GatewayEndpointState: Sendable, Equatable {
|
|
case ready(mode: AppState.ConnectionMode, url: URL, token: String?)
|
|
case unavailable(mode: AppState.ConnectionMode, reason: String)
|
|
}
|
|
|
|
/// Single place to resolve (and publish) the effective gateway control endpoint.
|
|
///
|
|
/// This is intentionally separate from `GatewayConnection`:
|
|
/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects).
|
|
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
|
actor GatewayEndpointStore {
|
|
static let shared = GatewayEndpointStore()
|
|
|
|
struct Deps: Sendable {
|
|
let mode: @Sendable () async -> AppState.ConnectionMode
|
|
let token: @Sendable () -> String?
|
|
let localPort: @Sendable () -> Int
|
|
let remotePortIfRunning: @Sendable () async -> UInt16?
|
|
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
|
|
|
static let live = Deps(
|
|
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
|
token: {
|
|
// First check env var, fallback to config file
|
|
if let envToken = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"], !envToken.isEmpty {
|
|
return envToken
|
|
}
|
|
return ClawdisConfigFile.gatewayPassword()
|
|
},
|
|
localPort: { GatewayEnvironment.gatewayPort() },
|
|
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
|
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
|
}
|
|
|
|
private let deps: Deps
|
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint")
|
|
|
|
private var state: GatewayEndpointState
|
|
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
|
|
|
|
init(deps: Deps = .live) {
|
|
self.deps = deps
|
|
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey)
|
|
let initialMode: AppState.ConnectionMode
|
|
if let modeRaw {
|
|
initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
|
} else {
|
|
let seen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
|
|
initialMode = seen ? .local : .unconfigured
|
|
}
|
|
|
|
let port = deps.localPort()
|
|
switch initialMode {
|
|
case .local:
|
|
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token())
|
|
case .remote:
|
|
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
|
case .unconfigured:
|
|
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
|
|
}
|
|
}
|
|
|
|
func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
|
|
let id = UUID()
|
|
let initial = self.state
|
|
let store = self
|
|
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
|
continuation.yield(initial)
|
|
self.subscribers[id] = continuation
|
|
continuation.onTermination = { @Sendable _ in
|
|
Task { await store.removeSubscriber(id) }
|
|
}
|
|
}
|
|
}
|
|
|
|
func refresh() async {
|
|
let mode = await self.deps.mode()
|
|
await self.setMode(mode)
|
|
}
|
|
|
|
func setMode(_ mode: AppState.ConnectionMode) async {
|
|
let token = self.deps.token()
|
|
switch mode {
|
|
case .local:
|
|
let port = self.deps.localPort()
|
|
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token))
|
|
case .remote:
|
|
let port = await self.deps.remotePortIfRunning()
|
|
guard let port else {
|
|
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
|
return
|
|
}
|
|
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token))
|
|
case .unconfigured:
|
|
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
|
}
|
|
}
|
|
|
|
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
|
|
func ensureRemoteControlTunnel() async throws -> UInt16 {
|
|
let mode = await self.deps.mode()
|
|
guard mode == .remote else {
|
|
throw NSError(
|
|
domain: "RemoteTunnel",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
|
}
|
|
let port = try await self.deps.ensureRemoteTunnel()
|
|
await self.setMode(.remote)
|
|
return port
|
|
}
|
|
|
|
func requireConfig() async throws -> GatewayConnection.Config {
|
|
await self.refresh()
|
|
switch self.state {
|
|
case let .ready(_, url, token):
|
|
return (url, token)
|
|
case let .unavailable(mode, reason):
|
|
guard mode == .remote else {
|
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
|
|
}
|
|
|
|
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
|
|
// recreate it on demand so callers can recover without a manual reconnect.
|
|
do {
|
|
let forwarded = try await self.deps.ensureRemoteTunnel()
|
|
let token = self.deps.token()
|
|
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
|
self.setState(.ready(mode: .remote, url: url, token: token))
|
|
return (url, token)
|
|
} catch {
|
|
let msg = "\(reason) (\(error.localizedDescription))"
|
|
self.setState(.unavailable(mode: .remote, reason: msg))
|
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeSubscriber(_ id: UUID) {
|
|
self.subscribers[id] = nil
|
|
}
|
|
|
|
private func setState(_ next: GatewayEndpointState) {
|
|
guard next != self.state else { return }
|
|
self.state = next
|
|
for (_, continuation) in self.subscribers {
|
|
continuation.yield(next)
|
|
}
|
|
switch next {
|
|
case let .ready(mode, url, _):
|
|
let modeDesc = String(describing: mode)
|
|
let urlDesc = url.absoluteString
|
|
self.logger
|
|
.debug(
|
|
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
|
|
case let .unavailable(mode, reason):
|
|
let modeDesc = String(describing: mode)
|
|
self.logger
|
|
.debug(
|
|
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
|
|
}
|
|
}
|
|
}
|