openclaw / src /gateway /server /ws-connection /connect-node-pairing-ssh.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4440aec verified
Raw History Blame Contribute Delete
5.76 kB
// Gateway WebSocket node pairing can finish a fresh capability-free request over SSH.
import { isDeepStrictEqual } from "node:util";
import type { ConnectPairingRequiredReason } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
import { getRuntimeConfigSnapshot } from "../../../config/runtime-snapshot.js";
import { approveDevicePairing } from "../../../infra/device-pairing-approval.js";
import { getPairedDevice } from "../../../infra/device-pairing.js";
import { isScopelessNodePairingRequest } from "../../node-pairing-auto-approve.js";
import {
planNodePairingSshVerify,
startNodePairingSshVerify,
} from "../../node-pairing-ssh-verify.js";
import type {
AuthenticatedGatewayConnect,
GatewayConnectPhaseContext,
} from "./message-handler-types.js";
type PairingRequest = Awaited<
ReturnType<typeof import("../../../infra/device-pairing.js").requestDevicePairing>
>;
export function startGatewayNodePairingSshApproval(params: {
context: GatewayConnectPhaseContext;
state: AuthenticatedGatewayConnect;
pairing: PairingRequest;
existingPairedDevice: Awaited<ReturnType<typeof getPairedDevice>> | null;
devicePublicKey: string;
clientAccessMetadata: {
displayName?: string;
remoteIp?: string;
lastSeenAtMs: number;
lastSeenReason: string;
};
reason: ConnectPairingRequiredReason;
}): boolean {
const { context, state, pairing, existingPairedDevice, devicePublicKey, clientAccessMetadata } =
params;
const {
connectParams,
configSnapshot,
reportedClientIp,
reportedClientIpSource,
hasBrowserOriginHeader,
runDetachedConnectWork,
} = context;
const { connId, buildRequestContext, logGateway } = context.handler;
const { device, role, scopes, isControlUi, isWebchat } = state;
if (!device || pairing.request.silent === true) {
return false;
}
// Gate on the request actually being approved, not just this
// connect's params: requestDevicePairing can refresh an older
// pending request in place (incomingApprovalCoveredByExisting), so a
// device could seed a scoped pending request, then reconnect
// scopeless from an SSH-verifiable host. SSH auto-approval must stay
// limited to a fresh node request that carries no roles/scopes
// beyond node.
if (!isScopelessNodePairingRequest(pairing.request)) {
return false;
}
const eligibility = {
existingPairedDevice: Boolean(existingPairedDevice),
role,
reason: params.reason,
scopes,
hasBrowserOriginHeader,
isControlUi,
isWebchat,
reportedClientIpSource,
reportedClientIp,
};
const sshVerifyPlan = planNodePairingSshVerify({
config: configSnapshot.gateway?.nodes?.pairing?.sshVerify,
eligibility,
});
const sshVerify = sshVerifyPlan
? startNodePairingSshVerify({
plan: sshVerifyPlan,
expectedDeviceId: device.id,
expectedPublicKey: devicePublicKey,
})
: null;
// A reconnect during an in-flight probe keeps the retry hint
// below but must not attach a second approval tail.
if (sshVerifyPlan && sshVerify && !sshVerify.alreadyInFlight) {
const pendingRequestId = pairing.request.requestId;
runDetachedConnectWork(
async () => {
const outcome = await sshVerify.done;
if (!outcome.ok) {
logGateway.info(
`node pairing ssh-verify did not approve device=${device.id} host=${sshVerifyPlan.host} reason=${outcome.reason}`,
);
return;
}
const approvedBySsh = await approveDevicePairing(pendingRequestId, {
callerScopes: scopes,
accessMetadata: clientAccessMetadata,
approvedVia: "ssh-verified",
isApprovalCurrent: ({ pending, existing }) => {
const currentConfig = getRuntimeConfigSnapshot();
if (
!currentConfig ||
pending.deviceId !== device.id ||
pending.publicKey !== devicePublicKey ||
!isScopelessNodePairingRequest(pending)
) {
return false;
}
// SSH proves this exact policy, not a later user/key/scope selection.
// The approval owner runs this after lock waits, directly before commit.
return isDeepStrictEqual(
sshVerifyPlan,
planNodePairingSshVerify({
config: currentConfig.gateway?.nodes?.pairing?.sshVerify,
eligibility: {
...eligibility,
existingPairedDevice: Boolean(existing),
scopes: pending.scopes ?? [],
},
}),
);
},
});
if (approvedBySsh?.status !== "approved") {
const reason =
approvedBySsh?.status === "forbidden"
? approvedBySsh.reason
: "request superseded or already resolved";
logGateway.info(
`node pairing ssh-verify approval skipped device=${device.id} (${reason})`,
);
return;
}
logGateway.info(
`security audit: device pairing ssh-verified auto-approve device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} sshUser=${outcome.user} client=${connectParams.client.id} conn=${connId}`,
);
buildRequestContext().broadcast(
"device.pair.resolved",
{
requestId: pendingRequestId,
deviceId: device.id,
decision: "approved",
ts: Date.now(),
},
{ dropIfSlow: true },
);
},
(error) => {
logGateway.warn(`node pairing ssh-verify failed device=${device.id}: ${String(error)}`);
},
);
}
return Boolean(sshVerifyPlan && sshVerify);
}