feat(pairing): add silent SSH auto-approve
This commit is contained in:
@@ -171,8 +171,12 @@ actor BridgeConnectionHandler {
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: req.caps,
|
||||
remoteAddress: self.remoteAddressString())
|
||||
commands: req.commands,
|
||||
remoteAddress: self.remoteAddressString(),
|
||||
silent: req.silent)
|
||||
let result = await context.handlePair(enriched)
|
||||
await self.handlePairResult(result, serverName: context.serverName)
|
||||
if case .ok = result {
|
||||
|
||||
@@ -19,6 +19,7 @@ final class NodePairingApprovalPrompter {
|
||||
private var activeRequestId: String?
|
||||
private var alertHostWindow: NSWindow?
|
||||
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
|
||||
private var autoApproveAttempts: Set<String> = []
|
||||
|
||||
private final class AlertHostWindow: NSWindow {
|
||||
override var canBecomeKey: Bool { true }
|
||||
@@ -47,6 +48,7 @@ final class NodePairingApprovalPrompter {
|
||||
let version: String?
|
||||
let remoteIp: String?
|
||||
let isRepair: Bool?
|
||||
let silent: Bool?
|
||||
let ts: Double
|
||||
|
||||
var id: String { self.requestId }
|
||||
@@ -90,6 +92,7 @@ final class NodePairingApprovalPrompter {
|
||||
self.alertHostWindow?.close()
|
||||
self.alertHostWindow = nil
|
||||
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
|
||||
self.autoApproveAttempts.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func loadPendingRequestsFromGateway() async {
|
||||
@@ -258,7 +261,13 @@ final class NodePairingApprovalPrompter {
|
||||
guard !self.isPresenting else { return }
|
||||
guard let next = self.queue.first else { return }
|
||||
self.isPresenting = true
|
||||
self.presentAlert(for: next)
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await self.trySilentApproveIfPossible(next) {
|
||||
return
|
||||
}
|
||||
self.presentAlert(for: next)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
@@ -330,7 +339,7 @@ final class NodePairingApprovalPrompter {
|
||||
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||
return
|
||||
case .alertSecondButtonReturn:
|
||||
await self.approve(requestId: request.requestId)
|
||||
_ = await self.approve(requestId: request.requestId)
|
||||
await self.notify(resolution: .approved, request: request, via: "local")
|
||||
case .alertThirdButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
@@ -340,13 +349,15 @@ final class NodePairingApprovalPrompter {
|
||||
}
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async {
|
||||
private func approve(requestId: String) async -> Bool {
|
||||
do {
|
||||
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
|
||||
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,4 +421,117 @@ final class NodePairingApprovalPrompter {
|
||||
sound: nil,
|
||||
priority: .active)
|
||||
}
|
||||
|
||||
private struct SSHTarget {
|
||||
let host: String
|
||||
let port: Int
|
||||
}
|
||||
|
||||
private func trySilentApproveIfPossible(_ req: PendingRequest) async -> Bool {
|
||||
guard req.silent == true else { return false }
|
||||
if self.autoApproveAttempts.contains(req.requestId) { return false }
|
||||
self.autoApproveAttempts.insert(req.requestId)
|
||||
|
||||
guard let target = await self.resolveSSHTarget() else {
|
||||
self.logger.info("silent pairing skipped (no ssh target) requestId=\(req.requestId, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !user.isEmpty else {
|
||||
self.logger.info("silent pairing skipped (missing local user) requestId=\(req.requestId, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
let ok = await Self.probeSSH(user: user, host: target.host, port: target.port)
|
||||
if !ok {
|
||||
self.logger.info("silent pairing probe failed requestId=\(req.requestId, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
guard await self.approve(requestId: req.requestId) else {
|
||||
self.logger.info("silent pairing approve failed requestId=\(req.requestId, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
await self.notify(resolution: .approved, request: req, via: "silent-ssh")
|
||||
if self.queue.first == req {
|
||||
self.queue.removeFirst()
|
||||
} else {
|
||||
self.queue.removeAll { $0 == req }
|
||||
}
|
||||
self.isPresenting = false
|
||||
self.presentNextIfNeeded()
|
||||
return true
|
||||
}
|
||||
|
||||
private func resolveSSHTarget() async -> SSHTarget? {
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
if !settings.target.isEmpty, let parsed = CommandResolver.parseSSHTarget(settings.target) {
|
||||
let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let targetUser = parsed.user,
|
||||
!targetUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
targetUser != user
|
||||
{
|
||||
self.logger.info("silent pairing skipped (ssh user mismatch)")
|
||||
return nil
|
||||
}
|
||||
let host = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else { return nil }
|
||||
let port = parsed.port > 0 ? parsed.port : 22
|
||||
return SSHTarget(host: host, port: port)
|
||||
}
|
||||
|
||||
let model = MasterDiscoveryModel()
|
||||
model.start()
|
||||
defer { model.stop() }
|
||||
|
||||
let deadline = Date().addingTimeInterval(5.0)
|
||||
while model.masters.isEmpty && Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
guard let master = model.masters.first else { return nil }
|
||||
let host = (master.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
||||
master.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||
guard let host, !host.isEmpty else { return nil }
|
||||
let port = master.sshPort > 0 ? master.sshPort : 22
|
||||
return SSHTarget(host: host, port: port)
|
||||
}
|
||||
|
||||
private static func probeSSH(user: String, host: String, port: Int) async -> Bool {
|
||||
await Task.detached(priority: .utility) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
|
||||
var args = [
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
"-o",
|
||||
"NumberOfPasswordPrompts=0",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
]
|
||||
if port > 0, port != 22 {
|
||||
args.append(contentsOf: ["-p", String(port)])
|
||||
}
|
||||
args.append(contentsOf: ["-l", user, host, "/usr/bin/true"])
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ public struct BridgePairRequest: Codable, Sendable {
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let remoteAddress: String?
|
||||
public let silent: Bool?
|
||||
|
||||
public init(
|
||||
type: String = "pair-request",
|
||||
@@ -125,7 +126,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
||||
modelIdentifier: String? = nil,
|
||||
caps: [String]? = nil,
|
||||
commands: [String]? = nil,
|
||||
remoteAddress: String? = nil)
|
||||
remoteAddress: String? = nil,
|
||||
silent: Bool? = nil)
|
||||
{
|
||||
self.type = type
|
||||
self.nodeId = nodeId
|
||||
@@ -137,6 +139,7 @@ public struct BridgePairRequest: Codable, Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.remoteAddress = remoteAddress
|
||||
self.silent = silent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ These are conceptual method names; wire them into `src/gateway/protocol/schema.t
|
||||
- `platform?` (string)
|
||||
- `version?` (string)
|
||||
- `remoteIp?` (string)
|
||||
- `silent?` (boolean) — hint that the UI may attempt auto-approval
|
||||
- `ts` (ms since epoch)
|
||||
- `node.pair.resolved`
|
||||
- Emitted when a pending request is approved/rejected.
|
||||
@@ -45,6 +46,7 @@ These are conceptual method names; wire them into `src/gateway/protocol/schema.t
|
||||
- `node.pair.request`
|
||||
- Creates (or returns) a pending request.
|
||||
- Params: node metadata (same shape as `node.pair.requested` payload, minus `requestId`/`ts`).
|
||||
- Optional `silent` flag hints that the UI can attempt an SSH auto-approve before showing an alert.
|
||||
- Result:
|
||||
- `status` ("pending")
|
||||
- `created` (boolean) — whether this call created the pending request
|
||||
@@ -98,6 +100,7 @@ Target direction:
|
||||
The macOS UI (Swift) can:
|
||||
- Subscribe to `node.pair.requested`, show an alert (including `remoteIp`), and call `node.pair.approve` or `node.pair.reject`.
|
||||
- Or ignore/dismiss (“Later”) and let CLI handle it.
|
||||
- When `silent` is set, it can try a short SSH probe (same user) and auto-approve if reachable; otherwise fall back to the normal alert.
|
||||
|
||||
## Implementation note
|
||||
If the bridge is only provided by the macOS app, then “no Swift app running” cannot work end-to-end.
|
||||
|
||||
@@ -227,6 +227,7 @@ export const NodePairRequestParamsSchema = Type.Object(
|
||||
caps: Type.Optional(Type.Array(NonEmptyString)),
|
||||
commands: Type.Optional(Type.Array(NonEmptyString)),
|
||||
remoteIp: Type.Optional(NonEmptyString),
|
||||
silent: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -3276,6 +3276,7 @@ export async function startGatewayServer(
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
remoteIp?: string;
|
||||
silent?: boolean;
|
||||
};
|
||||
try {
|
||||
const result = await requestNodePairing({
|
||||
@@ -3288,6 +3289,7 @@ export async function startGatewayServer(
|
||||
caps: p.caps,
|
||||
commands: p.commands,
|
||||
remoteIp: p.remoteIp,
|
||||
silent: p.silent,
|
||||
});
|
||||
if (result.status === "pending" && result.created) {
|
||||
broadcast("node.pair.requested", result.request, {
|
||||
|
||||
@@ -35,6 +35,7 @@ type BridgePairRequestFrame = {
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
remoteAddress?: string;
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
type BridgeEventFrame = {
|
||||
@@ -396,6 +397,7 @@ export async function startNodeBridgeServer(
|
||||
? req.commands.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
remoteIp: remoteAddress,
|
||||
silent: req.silent === true ? true : undefined,
|
||||
},
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export type NodePairingPendingRequest = {
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
remoteIp?: string;
|
||||
silent?: boolean;
|
||||
isRepair?: boolean;
|
||||
ts: number;
|
||||
};
|
||||
@@ -185,6 +186,7 @@ export async function requestNodePairing(
|
||||
caps: req.caps,
|
||||
commands: req.commands,
|
||||
remoteIp: req.remoteIp,
|
||||
silent: req.silent,
|
||||
isRepair,
|
||||
ts: Date.now(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user