fix: improve gateway ssh auth handling
This commit is contained in:
@@ -74,6 +74,7 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private(set) var lastPingMs: Double?
|
private(set) var lastPingMs: Double?
|
||||||
|
private(set) var authSourceLabel: String?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ final class ControlChannel {
|
|||||||
await GatewayConnection.shared.shutdown()
|
await GatewayConnection.shared.shutdown()
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
self.lastPingMs = nil
|
self.lastPingMs = nil
|
||||||
|
self.authSourceLabel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||||
@@ -188,8 +190,11 @@ final class ControlChannel {
|
|||||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||||
{
|
{
|
||||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||||
|
let tokenKey = CommandResolver.connectionModeIsRemote()
|
||||||
|
? "gateway.remote.token"
|
||||||
|
: "gateway.auth.token"
|
||||||
return
|
return
|
||||||
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
|
"Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||||
"or clear it on the gateway. " +
|
"or clear it on the gateway. " +
|
||||||
"Reason: \(reason)"
|
"Reason: \(reason)"
|
||||||
}
|
}
|
||||||
@@ -300,6 +305,27 @@ final class ControlChannel {
|
|||||||
code: 0,
|
code: 0,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||||
}
|
}
|
||||||
|
await self.refreshAuthSourceLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAuthSourceLabel() async {
|
||||||
|
let isRemote = CommandResolver.connectionModeIsRemote()
|
||||||
|
let authSource = await GatewayConnection.shared.authSource()
|
||||||
|
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
|
||||||
|
guard let source else { return nil }
|
||||||
|
switch source {
|
||||||
|
case .deviceToken:
|
||||||
|
return "Auth: device token (paired device)"
|
||||||
|
case .sharedToken:
|
||||||
|
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
||||||
|
case .password:
|
||||||
|
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
|
||||||
|
case .none:
|
||||||
|
return "Auth: none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ actor GatewayConnection {
|
|||||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authSource() async -> GatewayAuthSource? {
|
||||||
|
guard let client else { return nil }
|
||||||
|
return await client.authSource()
|
||||||
|
}
|
||||||
|
|
||||||
func shutdown() async {
|
func shutdown() async {
|
||||||
if let client {
|
if let client {
|
||||||
await client.shutdown()
|
await client.shutdown()
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ struct GeneralSettings: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||||
|
Text(authLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Tip: enable Tailscale for stable remote access.")
|
Text("Tip: enable Tailscale for stable remote access.")
|
||||||
|
|||||||
@@ -184,6 +184,14 @@ actor PortGuardian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
|
||||||
|
let listeners = await self.listeners(on: port)
|
||||||
|
if let pid {
|
||||||
|
return listeners.contains(where: { $0.pid == pid })
|
||||||
|
}
|
||||||
|
return !listeners.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
private func listeners(on port: Int) async -> [Listener] {
|
private func listeners(on port: Int) async -> [Listener] {
|
||||||
let res = await ShellExecutor.run(
|
let res = await ShellExecutor.run(
|
||||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ actor RemoteTunnelManager {
|
|||||||
tunnel.process.isRunning,
|
tunnel.process.isRunning,
|
||||||
let local = tunnel.localPort
|
let local = tunnel.localPort
|
||||||
{
|
{
|
||||||
if await self.isTunnelHealthy(port: local) {
|
let pid = tunnel.process.processIdentifier
|
||||||
|
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
||||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||||
return local
|
return local
|
||||||
}
|
}
|
||||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
self.logger.error(
|
||||||
|
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
||||||
await self.beginRestart()
|
await self.beginRestart()
|
||||||
tunnel.terminate()
|
tunnel.terminate()
|
||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
@@ -35,19 +37,11 @@ actor RemoteTunnelManager {
|
|||||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||||
self.isSshProcess(desc)
|
self.isSshProcess(desc)
|
||||||
{
|
{
|
||||||
if await self.isTunnelHealthy(port: desiredPort) {
|
self.logger.info(
|
||||||
self.logger.info(
|
"reusing existing SSH tunnel listener " +
|
||||||
"reusing existing SSH tunnel listener " +
|
"localPort=\(desiredPort, privacy: .public) " +
|
||||||
"localPort=\(desiredPort, privacy: .public) " +
|
"pid=\(desc.pid, privacy: .public)")
|
||||||
"pid=\(desc.pid, privacy: .public)")
|
return desiredPort
|
||||||
return desiredPort
|
|
||||||
}
|
|
||||||
if self.restartInFlight {
|
|
||||||
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
await self.beginRestart()
|
|
||||||
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -88,10 +82,6 @@ actor RemoteTunnelManager {
|
|||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
|
||||||
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||||
let cmd = desc.command.lowercased()
|
let cmd = desc.command.lowercased()
|
||||||
if cmd.contains("ssh") { return true }
|
if cmd.contains("ssh") { return true }
|
||||||
@@ -128,21 +118,5 @@ actor RemoteTunnelManager {
|
|||||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
||||||
let pid = desc.pid
|
|
||||||
self.logger.error(
|
|
||||||
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
|
|
||||||
let killed = await self.kill(pid: pid)
|
|
||||||
if !killed {
|
|
||||||
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
|
|
||||||
}
|
|
||||||
await PortGuardian.shared.removeRecord(pid: pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func kill(pid: Int32) async -> Bool {
|
|
||||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
|
||||||
if term.ok { return true }
|
|
||||||
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
|
||||||
return sigkill.ok
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum GatewayAuthSource: String, Sendable {
|
||||||
|
case deviceToken = "device-token"
|
||||||
|
case sharedToken = "shared-token"
|
||||||
|
case password = "password"
|
||||||
|
case none = "none"
|
||||||
|
}
|
||||||
|
|
||||||
// Avoid ambiguity with the app's own AnyCodable type.
|
// Avoid ambiguity with the app's own AnyCodable type.
|
||||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@@ -117,6 +124,7 @@ public actor GatewayChannelActor {
|
|||||||
private var lastSeq: Int?
|
private var lastSeq: Int?
|
||||||
private var lastTick: Date?
|
private var lastTick: Date?
|
||||||
private var tickIntervalMs: Double = 30000
|
private var tickIntervalMs: Double = 30000
|
||||||
|
private var lastAuthSource: GatewayAuthSource = .none
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let connectTimeoutSeconds: Double = 6
|
private let connectTimeoutSeconds: Double = 6
|
||||||
@@ -149,6 +157,8 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
|
||||||
|
|
||||||
public func shutdown() async {
|
public func shutdown() async {
|
||||||
self.shouldReconnect = false
|
self.shouldReconnect = false
|
||||||
self.connected = false
|
self.connected = false
|
||||||
@@ -300,6 +310,18 @@ public actor GatewayChannelActor {
|
|||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
||||||
let authToken = storedToken ?? self.token
|
let authToken = storedToken ?? self.token
|
||||||
|
let authSource: GatewayAuthSource
|
||||||
|
if storedToken != nil {
|
||||||
|
authSource = .deviceToken
|
||||||
|
} else if authToken != nil {
|
||||||
|
authSource = .sharedToken
|
||||||
|
} else if self.password != nil {
|
||||||
|
authSource = .password
|
||||||
|
} else {
|
||||||
|
authSource = .none
|
||||||
|
}
|
||||||
|
self.lastAuthSource = authSource
|
||||||
|
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
||||||
let canFallbackToShared = storedToken != nil && self.token != nil
|
let canFallbackToShared = storedToken != nil && self.token != nil
|
||||||
if let authToken {
|
if let authToken {
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||||
|
|||||||
@@ -285,5 +285,5 @@ sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
|
|||||||
|
|
||||||
verify_team_ids
|
verify_team_ids
|
||||||
|
|
||||||
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
|
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_RUNTIME"
|
||||||
echo "Codesign complete for $APP_BUNDLE"
|
echo "Codesign complete for $APP_BUNDLE"
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ describe("gateway-status command", () => {
|
|||||||
|
|
||||||
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
||||||
expect(probeGateway).toHaveBeenCalled();
|
expect(probeGateway).toHaveBeenCalled();
|
||||||
|
const tunnelCall = probeGateway.mock.calls.find(
|
||||||
|
(call) => typeof call?.[0]?.url === "string" && call[0].url.startsWith("ws://127.0.0.1:"),
|
||||||
|
)?.[0] as { auth?: { token?: string } } | undefined;
|
||||||
|
expect(tunnelCall?.auth?.token).toBe("rtok");
|
||||||
expect(sshStop).toHaveBeenCalledTimes(1);
|
expect(sshStop).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>;
|
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<string, unknown>;
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function resolveAuthForTarget(
|
|||||||
return { token: tokenOverride, password: passwordOverride };
|
return { token: tokenOverride, password: passwordOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.kind === "configRemote") {
|
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
|
||||||
const token =
|
const token =
|
||||||
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
|
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
|
||||||
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;
|
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;
|
||||||
|
|||||||
Reference in New Issue
Block a user