mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(gateway): auto-approve node pairing via SSH device-key verification (#104180)
This commit is contained in:
@@ -56,6 +56,7 @@ Approval behavior:
|
||||
- If the device is already paired and requests broader scopes or role, OpenClaw keeps the existing approval and creates a new pending upgrade request. Compare `Requested` vs `Approved` in `openclaw devices list`, or preview with `--latest`, before approving.
|
||||
- Approving a `node` role or other non-operator role requires `operator.admin`. `operator.pairing` is enough for operator-device approvals, but only when the requested operator scopes stay within the caller's own scopes. See [Operator scopes](/gateway/operator-scopes).
|
||||
- If `gateway.nodes.pairing.autoApproveCidrs` is configured, first-time `role: node` requests from matching client IPs can be auto-approved before they appear in this list. Disabled by default; never applies to operator/browser clients or upgrade requests.
|
||||
- `gateway.nodes.pairing.sshVerify` (on by default) auto-approves first-time `role: node` requests when the gateway verifies the device key over SSH to the node host. Requests may therefore resolve to approved shortly after appearing. Set `sshVerify: false` to disable SSH verification; this is independent of `autoApproveCidrs`, so unset that too for manual-only pairing.
|
||||
|
||||
### `openclaw devices reject <requestId>`
|
||||
|
||||
|
||||
+18
-1
@@ -129,13 +129,30 @@ the foreground flow so the pending request can be approved.
|
||||
## Pairing
|
||||
|
||||
The first connection creates a pending device pairing request (`role: node`) on the Gateway.
|
||||
Approve it via:
|
||||
|
||||
When the Gateway host can SSH to the node host non-interactively (same user,
|
||||
trusted host key), the pending request is approved automatically: the Gateway
|
||||
runs `openclaw node identity --json` on the node host over SSH and approves on
|
||||
an exact device-key match. This is on by default; see
|
||||
[SSH-verified device auto-approval](/gateway/pairing#ssh-verified-device-auto-approval-default)
|
||||
for requirements and how to disable it (`gateway.nodes.pairing.sshVerify: false`).
|
||||
|
||||
Otherwise approve manually via:
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
|
||||
Inspect the local node identity the Gateway verifies against:
|
||||
|
||||
```bash
|
||||
openclaw node identity --json
|
||||
```
|
||||
|
||||
It prints the device ID and public key from `identity/device.json` and never
|
||||
creates or modifies identity files.
|
||||
|
||||
On tightly controlled node networks, the Gateway operator can explicitly opt in
|
||||
to auto-approving first-time node pairing from trusted CIDRs:
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ These commands drive the gateway-owned `node.pair.*` store, separate from device
|
||||
- `remove` revokes the node's paired-role entry. For a device-backed node this revokes the `node` role in the device pairing store and disconnects its node-role sessions: a mixed-role device keeps its row and only loses the `node` role, a node-only device row is deleted. It also clears any matching legacy gateway-owned node pairing record.
|
||||
- `pending` only needs `operator.pairing` scope.
|
||||
- `gateway.nodes.pairing.autoApproveCidrs` can skip the pending step for explicitly trusted, first-time `role: node` device pairing. Off by default; does not approve role upgrades.
|
||||
- `gateway.nodes.pairing.sshVerify` (on by default) auto-approves first-time `role: node` device pairing when the gateway can verify the device key over SSH to the node host; the first capability surface is approved in the same step. See [Node pairing](/gateway/pairing#ssh-verified-device-auto-approval-default).
|
||||
- `approve` scope requirements follow the pending request's declared commands:
|
||||
- commandless request: `operator.pairing`
|
||||
- non-exec node commands: `operator.pairing` + `operator.write`
|
||||
|
||||
@@ -3589,6 +3589,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: API surface (gateway protocol)
|
||||
- H2: Node command gating (2026.3.31+)
|
||||
- H2: Node event trust boundaries (2026.3.31+)
|
||||
- H2: SSH-verified device auto-approval (default)
|
||||
- H2: Auto-approval (macOS app)
|
||||
- H2: Trusted-CIDR device auto-approval
|
||||
- H2: Silent pairing supersede cleanup
|
||||
|
||||
@@ -564,6 +564,12 @@ See [Inferred commitments](/concepts/commitments).
|
||||
pairing: {
|
||||
// Optional. Default unset/disabled.
|
||||
autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"],
|
||||
// SSH-verified auto-approval. Default: enabled (true).
|
||||
// Set false to disable SSH verification only; this does not affect
|
||||
// autoApproveCidrs above. For manual-only node pairing, set false AND
|
||||
// unset autoApproveCidrs. Pass an object to tune: { user, identity,
|
||||
// timeoutMs, cidrs }.
|
||||
sshVerify: true,
|
||||
},
|
||||
allowCommands: ["canvas.navigate"],
|
||||
denyCommands: ["system.run"],
|
||||
@@ -642,6 +648,7 @@ See [Inferred commitments](/concepts/commitments).
|
||||
- `trustedProxies`: reverse proxy IPs that terminate TLS or inject forwarded-client headers. Only list proxies you control. Loopback entries are still valid for same-host proxy/local-detection setups (for example Tailscale Serve or a local reverse proxy), but they do **not** make loopback requests eligible for `gateway.auth.mode: "trusted-proxy"`.
|
||||
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
|
||||
- `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades.
|
||||
- `gateway.nodes.pairing.sshVerify`: SSH-verified auto-approval for first-time node device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only on an exact `openclaw node identity` device-key match. Same eligibility floor as `autoApproveCidrs`; probes are limited to private/CGNAT source addresses unless `cidrs` overrides them. Set `false` to disable, or `{ user, identity, timeoutMs, cidrs }` to tune. See [Node pairing](/gateway/pairing#ssh-verified-device-auto-approval-default).
|
||||
- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. Use `allowCommands` to opt into dangerous node commands such as `camera.snap`, `camera.clip`, `screen.record`, `sms.search`, and `sms.send`; `denyCommands` removes a command even if a platform default or explicit allow would otherwise include it. Android SMS permission and Gateway command authorization are independent. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot.
|
||||
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
|
||||
- `gateway.tools.allow`: remove tool names from the default HTTP deny list for
|
||||
|
||||
+62
-8
@@ -139,11 +139,63 @@ sessions, and updates pairing metadata only when the device/node identity is
|
||||
already paired. A self-declared `client.id` value is not enough to write
|
||||
last-seen state.
|
||||
|
||||
## SSH-verified device auto-approval (default)
|
||||
|
||||
First-time `role: node` device pairing from a private/CGNAT address is
|
||||
auto-approved when the gateway can **prove machine ownership over SSH**: it
|
||||
connects back to the pairing host (`BatchMode`, `StrictHostKeyChecking=yes`),
|
||||
runs `openclaw node identity --json` there, and approves only when the remote
|
||||
device id and public key match the pending request exactly. The key match is
|
||||
what makes this safe: reachability alone never approves, so NAT co-tenants,
|
||||
other users on a shared host, and LAN spoofing all fall through to the normal
|
||||
prompt.
|
||||
|
||||
Enabled by default. Requirements for it to fire:
|
||||
|
||||
- The gateway process user (or `sshVerify.user`) can SSH to the node host
|
||||
non-interactively (keys/agent; Tailscale SSH works too), and the host key is
|
||||
already trusted.
|
||||
- `openclaw` resolves on the remote `PATH` for non-interactive `sh -lc`.
|
||||
- The connecting IP is a direct (non-proxied, non-loopback) private, ULA,
|
||||
link-local, or CGNAT address, or matches `sshVerify.cidrs` when set.
|
||||
- Same eligibility floor as trusted-CIDR approval: fresh scopeless node
|
||||
pairing only; upgrades, browsers, Control UI, and WebChat always prompt.
|
||||
|
||||
While a probe is running, the node client is told to keep retrying
|
||||
(`wait_then_retry`) instead of pausing for manual approval; if the probe
|
||||
fails, the next attempt falls back to the normal prompt flow. Failed targets
|
||||
get a short cooldown (5 minutes after a key mismatch).
|
||||
|
||||
Approved devices record `approvedVia: "ssh-verified"` and their first declared
|
||||
capability surface is approved in the same step — the key match already proves
|
||||
the node runs under the operator's account on a machine they own, which is the
|
||||
same claim a manual capability approval asserts. Later surface upgrades still
|
||||
prompt.
|
||||
|
||||
Harden or disable:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
pairing: {
|
||||
// Disable entirely:
|
||||
sshVerify: false,
|
||||
// ...or scope/tune the probe:
|
||||
// sshVerify: { user: "me", identity: "~/.ssh/probe", timeoutMs: 7000, cidrs: ["10.0.0.0/8"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-approval (macOS app)
|
||||
|
||||
The macOS app can attempt a **silent approval** when:
|
||||
The macOS app can attempt a **silent approval** of node capability requests
|
||||
when:
|
||||
|
||||
- the request is marked `silent`, and
|
||||
- the request is marked `silent` (the gateway marks the first capability
|
||||
surface silent when device pairing was approved non-interactively), and
|
||||
- the app can verify an SSH connection to the gateway host using the same
|
||||
user.
|
||||
|
||||
@@ -170,7 +222,9 @@ in with explicit CIDRs or exact IPs:
|
||||
Security boundary:
|
||||
|
||||
- Disabled when `gateway.nodes.pairing.autoApproveCidrs` is unset.
|
||||
- No blanket LAN or private-network auto-approve mode exists.
|
||||
- No blanket LAN or private-network auto-approve mode exists; SSH-verified
|
||||
auto-approval (above) requires a cryptographic device-key match, never
|
||||
network locality alone.
|
||||
- Only a fresh `role: node` device pairing request with no requested scopes is
|
||||
eligible.
|
||||
- Operator, browser, Control UI, and WebChat clients stay manual.
|
||||
@@ -182,7 +236,7 @@ Security boundary:
|
||||
|
||||
Non-interactive approvals record their provenance on the paired-device row:
|
||||
same-host local policy approvals as `silent`, trusted-CIDR node approvals as
|
||||
`trusted-cidr`. Clients whose state directory is ephemeral (temporary homes,
|
||||
`trusted-cidr`, SSH-verified node approvals as `ssh-verified`. Clients whose state directory is ephemeral (temporary homes,
|
||||
containers, per-run sandboxes) mint a fresh device keypair per run, and every
|
||||
run silently re-pairs as a brand-new device — without cleanup the paired list
|
||||
grows one stale row per run.
|
||||
@@ -198,10 +252,10 @@ removal event is broadcast.
|
||||
Boundaries:
|
||||
|
||||
- Only records whose latest approval was same-host local (`silent`) are
|
||||
eligible, as trigger and as target. Trusted-CIDR pairings cross hosts where
|
||||
display metadata is not a machine identity, so they are never removed
|
||||
automatically — use the Control UI cleanup or `openclaw nodes remove` for
|
||||
those.
|
||||
eligible, as trigger and as target. Trusted-CIDR and SSH-verified pairings
|
||||
cross hosts where display metadata is not a machine identity, so they are
|
||||
never removed automatically — use the Control UI cleanup or
|
||||
`openclaw nodes remove` for those.
|
||||
- Owner-approved and QR/setup-code (bootstrap) pairings are never removed
|
||||
automatically. Records approved before provenance existed stay protected,
|
||||
even after a later silent re-approval of the same device id.
|
||||
|
||||
@@ -106,6 +106,7 @@ Quick model for triaging risk reports:
|
||||
| Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" |
|
||||
| Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" |
|
||||
| `gateway.nodes.pairing.autoApproveCidrs` | Opt-in trusted-network node enrollment policy | "A disabled-by-default allowlist is an automatic pairing vulnerability" |
|
||||
| `gateway.nodes.pairing.sshVerify` | Key-verified node enrollment over operator SSH | "Default-on auto-approval is an automatic pairing vulnerability" |
|
||||
|
||||
## Not vulnerabilities by design
|
||||
|
||||
@@ -117,6 +118,7 @@ Quick model for triaging risk reports:
|
||||
- Localhost-only deployment findings (for example missing HSTS on a loopback-only gateway).
|
||||
- Discord inbound webhook signature findings for inbound paths that do not exist in this repo.
|
||||
- Node pairing metadata treated as a hidden second per-command approval layer for `system.run`; the real execution boundary is the gateway's global node command policy plus the node's own exec approvals.
|
||||
- `gateway.nodes.pairing.sshVerify` treated as a vulnerability because it is enabled by default. It never approves on network locality or SSH reachability alone: the gateway reads the device identity back over SSH (BatchMode, strict host keys) and approves only on an exact device-key match with the pending request, which requires the connecting keypair to already live under the operator's account on a host the operator controls. Probes are bounded to private/CGNAT source addresses, share the trusted-CIDR eligibility floor (fresh scopeless `role: node` only), and `sshVerify: false` turns the feature off.
|
||||
- `gateway.nodes.pairing.autoApproveCidrs` treated as a vulnerability by itself. It is disabled by default, requires explicit CIDR/IP entries, only applies to first-time `role: node` pairing with no requested scopes, and never auto-approves operator/browser/Control UI, WebChat, role/scope upgrades, metadata or public-key changes, or same-host loopback trusted-proxy header paths (even when loopback trusted-proxy auth is enabled).
|
||||
- "Missing per-user authorization" findings that treat `sessionKey` as an auth token.
|
||||
|
||||
|
||||
@@ -261,6 +261,9 @@ Node-related settings live under `gateway.nodes` and `tools.exec`:
|
||||
// with no requested scopes; does not auto-approve upgrades.
|
||||
pairing: {
|
||||
autoApproveCidrs: ["192.168.1.0/24"],
|
||||
// SSH-verified auto-approval (default: enabled). Approves first-time
|
||||
// node pairing on an exact device-key match read back over SSH.
|
||||
sshVerify: true,
|
||||
},
|
||||
// Opt into dangerous/privacy-heavy node commands (camera.snap, etc.).
|
||||
allowCommands: ["camera.snap", "screen.record"],
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Node identity CLI tests: read-only output of the node host device identity.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
} from "../../infra/device-identity.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { runNodeIdentityShow } from "./identity.js";
|
||||
|
||||
describe("runNodeIdentityShow", () => {
|
||||
let stateDir: string;
|
||||
let prevStateDir: string | undefined;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-identity-"));
|
||||
prevStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
logSpy = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = prevStateDir;
|
||||
}
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("fails closed when no identity exists (never mints one)", () => {
|
||||
runNodeIdentityShow({});
|
||||
expect(errorSpy).toHaveBeenCalledOnce();
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(fs.existsSync(path.join(stateDir, "identity", "device.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("prints deviceId and raw public key as JSON", () => {
|
||||
const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json"));
|
||||
runNodeIdentityShow({ json: true });
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledOnce();
|
||||
const parsed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
};
|
||||
expect(parsed).toEqual({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
});
|
||||
});
|
||||
|
||||
it("prints human-readable lines without --json", () => {
|
||||
const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json"));
|
||||
runNodeIdentityShow({});
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
const output = logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
|
||||
expect(output).toContain(identity.deviceId);
|
||||
expect(output).toContain(publicKeyRawBase64UrlFromPem(identity.publicKeyPem));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Prints the local node host device identity for pairing verification.
|
||||
import {
|
||||
loadDeviceIdentityIfPresent,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
} from "../../infra/device-identity.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
/**
|
||||
* Read-only by design: the SSH-verified pairing probe calls this remotely and
|
||||
* must never mint a fresh identity on a host that has not run the node host.
|
||||
*/
|
||||
export function runNodeIdentityShow(opts: { json?: boolean }) {
|
||||
const identity = loadDeviceIdentityIfPresent();
|
||||
if (!identity) {
|
||||
defaultRuntime.error(
|
||||
"no node device identity found (start the node host once with `openclaw node run` or `openclaw node install`)",
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
};
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(payload));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`deviceId: ${payload.deviceId}`);
|
||||
defaultRuntime.log(`publicKey: ${payload.publicKey}`);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ type LoadNodeHostConfig = typeof import("../../node-host/config.js").loadNodeHos
|
||||
|
||||
const daemonMocks = vi.hoisted(() => ({
|
||||
defaultRuntime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
runNodeDaemonStop,
|
||||
runNodeDaemonUninstall,
|
||||
} from "./daemon.js";
|
||||
import { runNodeIdentityShow } from "./identity.js";
|
||||
|
||||
function parsePortOption(value: unknown, fallback: number): number | null {
|
||||
// Undefined keeps config/default port; invalid explicit input returns null for CLI errors.
|
||||
@@ -103,6 +104,14 @@ export function registerNodeCli(program: Command) {
|
||||
await runNodeDaemonStatus(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("identity")
|
||||
.description("Print the node host device identity (device id + public key)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action((opts) => {
|
||||
runNodeIdentityShow(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("install")
|
||||
.description("Install the node host service (launchd/systemd/schtasks)")
|
||||
|
||||
@@ -640,9 +640,11 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).',
|
||||
"gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).",
|
||||
"gateway.nodes.pairing":
|
||||
"Node pairing policy settings. Defaults keep CIDR auto-approval disabled; enable only with explicit trusted CIDR/IP allowlists you control.",
|
||||
"Node pairing policy settings. SSH-verified auto-approval is enabled by default; CIDR auto-approval stays disabled unless explicit trusted CIDR/IP allowlists are configured.",
|
||||
"gateway.nodes.pairing.autoApproveCidrs":
|
||||
"Opt-in CIDR/IP allowlist for auto-approving first-time node-role device pairing with no requested scopes. Disabled when unset. Operator, browser, Control UI, and any role, scope, metadata, or public-key upgrade pairing still require manual approval.",
|
||||
"gateway.nodes.pairing.sshVerify":
|
||||
"SSH-verified auto-approval for first-time node-role device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only when the remote `openclaw node identity` output matches the pending device key. Set false to disable SSH verification (independent of autoApproveCidrs, which stays active); for manual-only pairing also unset autoApproveCidrs. Pass an object to override user/identity/timeoutMs/cidrs.",
|
||||
"gateway.nodes.allowCommands":
|
||||
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.",
|
||||
"gateway.nodes.denyCommands":
|
||||
|
||||
@@ -401,6 +401,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.nodes.browser.node": "Gateway Node Browser Pin",
|
||||
"gateway.nodes.pairing": "Gateway Node Pairing",
|
||||
"gateway.nodes.pairing.autoApproveCidrs": "Gateway Node Pairing Auto-Approve CIDRs",
|
||||
"gateway.nodes.pairing.sshVerify": "Gateway Node Pairing SSH Verification",
|
||||
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
|
||||
"gateway.nodes.denyCommands": "Gateway Node Denylist",
|
||||
nodeHost: "Node Host",
|
||||
|
||||
@@ -58,6 +58,7 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"],
|
||||
"gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"],
|
||||
"gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"],
|
||||
"gateway.nodes.pairing.sshVerify": ["security", "access", "network", "advanced"],
|
||||
"proxy.tls.caFile": ["security", "network", "storage", "advanced"],
|
||||
"tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"],
|
||||
"tools.exec.mode": ["tools", "security", "access"],
|
||||
|
||||
@@ -479,6 +479,26 @@ export type GatewayNodePairingConfig = {
|
||||
* Default: unset/disabled.
|
||||
*/
|
||||
autoApproveCidrs?: string[];
|
||||
/**
|
||||
* SSH-verified auto-approval for first-time node-role pairing (default: enabled).
|
||||
* The gateway connects back to the pairing host over SSH (BatchMode, strict
|
||||
* host keys) and approves only when the remote `openclaw node identity`
|
||||
* output matches the pending request's device key. Set false to disable SSH
|
||||
* verification; this is independent of autoApproveCidrs, so unset that too for
|
||||
* manual-only node pairing. The object form tunes the probe:
|
||||
* - user: remote user (default: gateway process user)
|
||||
* - identity: SSH identity file (default: standard SSH resolution)
|
||||
* - timeoutMs: probe timeout (default: 7000)
|
||||
* - cidrs: CIDRs/IPs eligible for probing (default: private/CGNAT ranges)
|
||||
*/
|
||||
sshVerify?:
|
||||
| boolean
|
||||
| {
|
||||
user?: string;
|
||||
identity?: string;
|
||||
timeoutMs?: number;
|
||||
cidrs?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type GatewayNodesConfig = {
|
||||
|
||||
@@ -1398,6 +1398,19 @@ export const OpenClawSchema = z
|
||||
pairing: z
|
||||
.object({
|
||||
autoApproveCidrs: z.array(z.string()).optional(),
|
||||
sshVerify: z
|
||||
.union([
|
||||
z.boolean(),
|
||||
z
|
||||
.object({
|
||||
user: z.string().optional(),
|
||||
identity: z.string().optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
cidrs: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -81,6 +81,39 @@ describe("reconcileNodePairingOnConnect", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the first-surface request silent when device pairing was non-interactive", async () => {
|
||||
const requestPairing = makePendingPairingRequest("req-silent");
|
||||
|
||||
await reconcileNodePairingOnConnect({
|
||||
cfg: {} as never,
|
||||
connectParams: makeNodeConnectParams(),
|
||||
pairedNode: null,
|
||||
initialSurfaceSilent: true,
|
||||
requestPairing,
|
||||
});
|
||||
|
||||
expect(requestPairing).toHaveBeenCalledWith(expect.objectContaining({ silent: true }));
|
||||
});
|
||||
|
||||
it("keeps surface upgrade requests interactive even when the device paired silently", async () => {
|
||||
const requestPairing = makePendingPairingRequest("req-upgrade-loud");
|
||||
|
||||
await reconcileNodePairingOnConnect({
|
||||
cfg: {} as never,
|
||||
connectParams: makeNodeConnectParams({
|
||||
caps: ["camera", "screen"],
|
||||
commands: [],
|
||||
}),
|
||||
pairedNode: makePairedNode({ caps: ["camera"], commands: [] }),
|
||||
initialSurfaceSilent: true,
|
||||
requestPairing,
|
||||
});
|
||||
|
||||
expect(requestPairing).toHaveBeenCalledOnce();
|
||||
const input = requestPairing.mock.calls[0]?.[0];
|
||||
expect(input?.silent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps first-time pending node surfaces declared but not effective", async () => {
|
||||
const requestPairing = makePendingPairingRequest("req-pending");
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ function buildNodePairingRequestInput(params: {
|
||||
commands: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
remoteIp?: string;
|
||||
silent?: boolean;
|
||||
}): NodePairingRequestInput {
|
||||
return {
|
||||
nodeId: params.nodeId,
|
||||
@@ -110,6 +111,7 @@ function buildNodePairingRequestInput(params: {
|
||||
commands: params.commands,
|
||||
permissions: params.permissions,
|
||||
remoteIp: params.remoteIp,
|
||||
...(params.silent ? { silent: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +121,12 @@ export async function reconcileNodePairingOnConnect(params: {
|
||||
connectParams: ConnectParams;
|
||||
pairedNode: NodePairingPairedNode | null;
|
||||
reportedClientIp?: string;
|
||||
/**
|
||||
* Marks the first-surface capability request silent when device pairing was
|
||||
* approved non-interactively; approval UIs may then auto-approve it (macOS
|
||||
* SSH trust probe) instead of prompting. Upgrade requests stay interactive.
|
||||
*/
|
||||
initialSurfaceSilent?: boolean;
|
||||
requestPairing: (input: NodePairingRequestInput) => Promise<RequestNodePairingResult | null>;
|
||||
}): Promise<NodeConnectPairingReconcileResult> {
|
||||
const nodeId = params.connectParams.device?.id ?? params.connectParams.client.id;
|
||||
@@ -147,6 +155,7 @@ export async function reconcileNodePairingOnConnect(params: {
|
||||
commands: declared,
|
||||
permissions: declaredPermissions,
|
||||
remoteIp: params.reportedClientIp,
|
||||
silent: params.initialSurfaceSilent,
|
||||
}),
|
||||
);
|
||||
if (!pendingPairing) {
|
||||
|
||||
@@ -8,7 +8,7 @@ export type NodePairingAutoApproveReason =
|
||||
| "scope-upgrade"
|
||||
| "metadata-upgrade";
|
||||
|
||||
type NodePairingAutoApproveClientIpSource =
|
||||
export type NodePairingAutoApproveClientIpSource =
|
||||
| "direct"
|
||||
| "trusted-proxy"
|
||||
| "loopback-trusted-proxy"
|
||||
@@ -30,8 +30,8 @@ export function resolveNodePairingClientIpSource(params: {
|
||||
return params.remoteIsLoopback ? "loopback-trusted-proxy" : "trusted-proxy";
|
||||
}
|
||||
|
||||
/** Returns true when a node pairing request can be auto-approved by trusted CIDR policy. */
|
||||
export function shouldAutoApproveNodePairingFromTrustedCidrs(params: {
|
||||
/** Shared eligibility inputs for non-interactive first-time node pairing approvals. */
|
||||
export type FreshNodePairingEligibilityParams = {
|
||||
existingPairedDevice: boolean;
|
||||
role: string;
|
||||
reason: NodePairingAutoApproveReason;
|
||||
@@ -41,8 +41,17 @@ export function shouldAutoApproveNodePairingFromTrustedCidrs(params: {
|
||||
isWebchat: boolean;
|
||||
reportedClientIpSource: NodePairingAutoApproveClientIpSource;
|
||||
reportedClientIp?: string;
|
||||
autoApproveCidrs?: readonly string[];
|
||||
}): boolean {
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared floor for every non-interactive node pairing approval (trusted-CIDR,
|
||||
* SSH-verified): only a fresh, scopeless, non-browser `role: node` request
|
||||
* with a directly attributable client IP qualifies. Upgrades and spoofable
|
||||
* loopback trusted-proxy header paths always stay on the manual prompt.
|
||||
*/
|
||||
export function isEligibleFreshNodePairingRequest(
|
||||
params: FreshNodePairingEligibilityParams,
|
||||
): boolean {
|
||||
if (params.existingPairedDevice) {
|
||||
return false;
|
||||
}
|
||||
@@ -64,7 +73,16 @@ export function shouldAutoApproveNodePairingFromTrustedCidrs(params: {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!params.reportedClientIp) {
|
||||
return Boolean(params.reportedClientIp);
|
||||
}
|
||||
|
||||
/** Returns true when a node pairing request can be auto-approved by trusted CIDR policy. */
|
||||
export function shouldAutoApproveNodePairingFromTrustedCidrs(
|
||||
params: FreshNodePairingEligibilityParams & {
|
||||
autoApproveCidrs?: readonly string[];
|
||||
},
|
||||
): boolean {
|
||||
if (!isEligibleFreshNodePairingRequest(params) || !params.reportedClientIp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// SSH probe execution for SSH-verified node pairing.
|
||||
// Kept as a narrow runtime boundary so gateway tests can mock the spawn
|
||||
// without touching the eligibility/verification policy.
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export type NodeIdentityProbeParams = {
|
||||
user: string;
|
||||
host: string;
|
||||
port?: number;
|
||||
identity?: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type NodeIdentityProbeResult =
|
||||
| { status: "ok"; stdout: string }
|
||||
| { status: "failed"; code: number | null; stderr: string }
|
||||
| { status: "timeout" }
|
||||
| { status: "spawn-error"; message: string };
|
||||
|
||||
const MAX_PROBE_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
// `sh -lc` loads the remote login profile so `openclaw` resolves on PATH even
|
||||
// though sshd runs remote commands through a non-login shell.
|
||||
const REMOTE_IDENTITY_COMMAND = "sh -lc 'openclaw node identity --json'";
|
||||
|
||||
/** Read the node device identity back from the pairing host over SSH. */
|
||||
export async function runNodeIdentityProbe(
|
||||
params: NodeIdentityProbeParams,
|
||||
): Promise<NodeIdentityProbeResult> {
|
||||
const args = [
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
"-o",
|
||||
"NumberOfPasswordPrompts=0",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
// Auto-approval is an authorization boundary; only hosts whose key is
|
||||
// already trusted may vouch for a pairing request.
|
||||
"-o",
|
||||
"StrictHostKeyChecking=yes",
|
||||
"-o",
|
||||
"UpdateHostKeys=no",
|
||||
// The probe target is chosen from the connecting client's IP, i.e. an
|
||||
// untrusted host until the key match succeeds. Never expose the gateway
|
||||
// user's agent, X11, or any port forward to it, even if the user's ssh
|
||||
// config enables forwarding for a matching host.
|
||||
"-a",
|
||||
"-x",
|
||||
"-o",
|
||||
"ForwardAgent=no",
|
||||
"-o",
|
||||
"ForwardX11=no",
|
||||
"-o",
|
||||
"ForwardX11Trusted=no",
|
||||
"-o",
|
||||
"ClearAllForwardings=yes",
|
||||
"-o",
|
||||
"ExitOnForwardFailure=yes",
|
||||
"-p",
|
||||
String(params.port ?? 22),
|
||||
];
|
||||
if (params.identity?.trim()) {
|
||||
args.push("-i", params.identity.trim(), "-o", "IdentitiesOnly=yes");
|
||||
}
|
||||
// Security: '--' prevents the user@host target from being read as an option.
|
||||
args.push("--", `${params.user}@${params.host}`, REMOTE_IDENTITY_COMMAND);
|
||||
|
||||
return await new Promise<NodeIdentityProbeResult>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: NodeIdentityProbeResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
// PATH-resolved `ssh` keeps Windows OpenSSH working; the gateway process
|
||||
// environment is operator-owned, so PATH lookup is not an injection risk.
|
||||
const child = spawn("ssh", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 1_500).unref();
|
||||
} catch {
|
||||
// Best-effort teardown; the probe already reports timeout.
|
||||
}
|
||||
settle({ status: "timeout" });
|
||||
},
|
||||
Math.max(250, params.timeoutMs),
|
||||
);
|
||||
timer.unref?.();
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const append = (current: string, chunk: unknown): string =>
|
||||
current.length >= MAX_PROBE_OUTPUT_BYTES
|
||||
? current
|
||||
: (current + String(chunk)).slice(0, MAX_PROBE_OUTPUT_BYTES);
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout = append(stdout, chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr = append(stderr, chunk);
|
||||
});
|
||||
child.stdout?.on("error", () => {});
|
||||
child.stderr?.on("error", () => {});
|
||||
child.once("error", (error) => {
|
||||
settle({ status: "spawn-error", message: error.message });
|
||||
});
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) {
|
||||
settle({ status: "ok", stdout });
|
||||
} else {
|
||||
settle({ status: "failed", code, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// SSH-verified node pairing policy and verifier tests (probe injected).
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
deriveDeviceIdFromPublicKey,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
} from "../infra/device-identity.js";
|
||||
import type { FreshNodePairingEligibilityParams } from "./node-pairing-auto-approve.js";
|
||||
import {
|
||||
planNodePairingSshVerify,
|
||||
resetNodePairingSshVerifyStateForTests,
|
||||
resolveNodePairingSshVerifyPolicy,
|
||||
startNodePairingSshVerify,
|
||||
type NodePairingSshVerifyPlan,
|
||||
} from "./node-pairing-ssh-verify.js";
|
||||
import type {
|
||||
NodeIdentityProbeParams,
|
||||
NodeIdentityProbeResult,
|
||||
} from "./node-pairing-ssh-verify.runtime.js";
|
||||
|
||||
function makeIdentity(): { deviceId: string; publicKey: string } {
|
||||
const { publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }) as string;
|
||||
const raw = publicKeyRawBase64UrlFromPem(publicKeyPem);
|
||||
const deviceId = deriveDeviceIdFromPublicKey(raw);
|
||||
if (!deviceId) {
|
||||
throw new Error("failed to derive device id for test identity");
|
||||
}
|
||||
return { deviceId, publicKey: raw };
|
||||
}
|
||||
|
||||
function makeEligibility(
|
||||
overrides?: Partial<FreshNodePairingEligibilityParams>,
|
||||
): FreshNodePairingEligibilityParams {
|
||||
return {
|
||||
existingPairedDevice: false,
|
||||
role: "node",
|
||||
reason: "not-paired",
|
||||
scopes: [],
|
||||
hasBrowserOriginHeader: false,
|
||||
isControlUi: false,
|
||||
isWebchat: false,
|
||||
reportedClientIpSource: "direct",
|
||||
reportedClientIp: "192.168.1.20",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePlan(host = "192.168.1.20"): NodePairingSshVerifyPlan {
|
||||
return {
|
||||
policy: { user: "tester", timeoutMs: 1_000 },
|
||||
host,
|
||||
};
|
||||
}
|
||||
|
||||
function probeReturning(result: NodeIdentityProbeResult) {
|
||||
const calls: NodeIdentityProbeParams[] = [];
|
||||
const probe = async (params: NodeIdentityProbeParams) => {
|
||||
calls.push(params);
|
||||
return result;
|
||||
};
|
||||
return { probe, calls };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetNodePairingSshVerifyStateForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetNodePairingSshVerifyStateForTests();
|
||||
});
|
||||
|
||||
describe("resolveNodePairingSshVerifyPolicy", () => {
|
||||
test("is enabled by default and honors explicit true", () => {
|
||||
for (const raw of [undefined, true] as const) {
|
||||
const policy = resolveNodePairingSshVerifyPolicy(raw);
|
||||
expect(policy).not.toBeNull();
|
||||
expect(policy?.user.length).toBeGreaterThan(0);
|
||||
expect(policy?.timeoutMs).toBe(7_000);
|
||||
}
|
||||
});
|
||||
|
||||
test("false disables the policy", () => {
|
||||
expect(resolveNodePairingSshVerifyPolicy(false)).toBeNull();
|
||||
});
|
||||
|
||||
test("object form overrides user, identity, timeout, and cidrs", () => {
|
||||
const policy = resolveNodePairingSshVerifyPolicy({
|
||||
user: "peter",
|
||||
identity: "/keys/probe",
|
||||
timeoutMs: 1234,
|
||||
cidrs: ["10.0.0.0/8", " "],
|
||||
});
|
||||
expect(policy).toEqual({
|
||||
user: "peter",
|
||||
identity: "/keys/probe",
|
||||
timeoutMs: 1234,
|
||||
cidrs: ["10.0.0.0/8"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("planNodePairingSshVerify", () => {
|
||||
test("plans a probe for an eligible private-network node pairing", () => {
|
||||
const plan = planNodePairingSshVerify({
|
||||
config: undefined,
|
||||
eligibility: makeEligibility(),
|
||||
});
|
||||
expect(plan?.host).toBe("192.168.1.20");
|
||||
});
|
||||
|
||||
test("strips the IPv4-mapped IPv6 prefix from the probe target", () => {
|
||||
const plan = planNodePairingSshVerify({
|
||||
config: undefined,
|
||||
eligibility: makeEligibility({ reportedClientIp: "::ffff:192.168.1.21" }),
|
||||
});
|
||||
expect(plan?.host).toBe("192.168.1.21");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["disabled config", { config: false as const, eligibility: makeEligibility() }],
|
||||
[
|
||||
"existing paired device",
|
||||
{ config: undefined, eligibility: makeEligibility({ existingPairedDevice: true }) },
|
||||
],
|
||||
["operator role", { config: undefined, eligibility: makeEligibility({ role: "operator" }) }],
|
||||
[
|
||||
"upgrade reason",
|
||||
{ config: undefined, eligibility: makeEligibility({ reason: "scope-upgrade" }) },
|
||||
],
|
||||
[
|
||||
"requested scopes",
|
||||
{ config: undefined, eligibility: makeEligibility({ scopes: ["operator.read"] }) },
|
||||
],
|
||||
[
|
||||
"browser origin",
|
||||
{ config: undefined, eligibility: makeEligibility({ hasBrowserOriginHeader: true }) },
|
||||
],
|
||||
[
|
||||
"spoofable loopback proxy source",
|
||||
{
|
||||
config: undefined,
|
||||
eligibility: makeEligibility({ reportedClientIpSource: "loopback-trusted-proxy" }),
|
||||
},
|
||||
],
|
||||
[
|
||||
"public source address",
|
||||
{ config: undefined, eligibility: makeEligibility({ reportedClientIp: "203.0.113.9" }) },
|
||||
],
|
||||
[
|
||||
"loopback source address",
|
||||
{ config: undefined, eligibility: makeEligibility({ reportedClientIp: "127.0.0.1" }) },
|
||||
],
|
||||
[
|
||||
"zone-scoped IPv6 literal",
|
||||
{ config: undefined, eligibility: makeEligibility({ reportedClientIp: "fe80::1%en0" }) },
|
||||
],
|
||||
])("returns null for %s", (_label, params) => {
|
||||
expect(planNodePairingSshVerify(params)).toBeNull();
|
||||
});
|
||||
|
||||
test("custom cidrs replace the default private-range probe scope", () => {
|
||||
const config = { cidrs: ["10.1.0.0/16"] };
|
||||
expect(
|
||||
planNodePairingSshVerify({
|
||||
config,
|
||||
eligibility: makeEligibility({ reportedClientIp: "192.168.1.20" }),
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
planNodePairingSshVerify({
|
||||
config,
|
||||
eligibility: makeEligibility({ reportedClientIp: "10.1.2.3" }),
|
||||
})?.host,
|
||||
).toBe("10.1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("startNodePairingSshVerify", () => {
|
||||
test("approves when the remote identity matches, tolerating login-shell noise", async () => {
|
||||
const identity = makeIdentity();
|
||||
const { probe, calls } = probeReturning({
|
||||
status: "ok",
|
||||
stdout: `Welcome to devbox\n{"deviceId":"${identity.deviceId}","publicKey":"${identity.publicKey}"}\n`,
|
||||
});
|
||||
const started = startNodePairingSshVerify({
|
||||
plan: makePlan(),
|
||||
expectedDeviceId: identity.deviceId,
|
||||
expectedPublicKey: identity.publicKey,
|
||||
probe,
|
||||
});
|
||||
expect(started).not.toBeNull();
|
||||
await expect(started?.done).resolves.toEqual({
|
||||
ok: true,
|
||||
user: "tester",
|
||||
host: "192.168.1.20",
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{ user: "tester", host: "192.168.1.20", identity: undefined, timeoutMs: 1_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects a remote identity with a different key and cools the target down", async () => {
|
||||
const expected = makeIdentity();
|
||||
const other = makeIdentity();
|
||||
const { probe } = probeReturning({
|
||||
status: "ok",
|
||||
stdout: `{"deviceId":"${expected.deviceId}","publicKey":"${other.publicKey}"}\n`,
|
||||
});
|
||||
const started = startNodePairingSshVerify({
|
||||
plan: makePlan(),
|
||||
expectedDeviceId: expected.deviceId,
|
||||
expectedPublicKey: expected.publicKey,
|
||||
probe,
|
||||
});
|
||||
await expect(started?.done).resolves.toEqual({ ok: false, reason: "identity-mismatch" });
|
||||
expect(
|
||||
startNodePairingSshVerify({
|
||||
plan: makePlan(),
|
||||
expectedDeviceId: expected.deviceId,
|
||||
expectedPublicKey: expected.publicKey,
|
||||
probe,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a matching key under a different device id", async () => {
|
||||
const identity = makeIdentity();
|
||||
const { probe } = probeReturning({
|
||||
status: "ok",
|
||||
stdout: `{"deviceId":"someone-else","publicKey":"${identity.publicKey}"}\n`,
|
||||
});
|
||||
const started = startNodePairingSshVerify({
|
||||
plan: makePlan(),
|
||||
expectedDeviceId: identity.deviceId,
|
||||
expectedPublicKey: identity.publicKey,
|
||||
probe,
|
||||
});
|
||||
await expect(started?.done).resolves.toEqual({ ok: false, reason: "identity-mismatch" });
|
||||
});
|
||||
|
||||
test("reports probe failures and unreadable identities distinctly", async () => {
|
||||
const identity = makeIdentity();
|
||||
const failed = startNodePairingSshVerify({
|
||||
plan: makePlan("192.168.1.30"),
|
||||
expectedDeviceId: identity.deviceId,
|
||||
expectedPublicKey: identity.publicKey,
|
||||
probe: probeReturning({ status: "failed", code: 255, stderr: "denied" }).probe,
|
||||
});
|
||||
await expect(failed?.done).resolves.toEqual({ ok: false, reason: "probe-failed" });
|
||||
|
||||
const unreadable = startNodePairingSshVerify({
|
||||
plan: makePlan("192.168.1.31"),
|
||||
expectedDeviceId: identity.deviceId,
|
||||
expectedPublicKey: identity.publicKey,
|
||||
probe: probeReturning({ status: "ok", stdout: "no json here\n" }).probe,
|
||||
});
|
||||
await expect(unreadable?.done).resolves.toEqual({ ok: false, reason: "identity-unreadable" });
|
||||
});
|
||||
|
||||
test("shares an in-flight probe with reconnects instead of starting another", async () => {
|
||||
const identity = makeIdentity();
|
||||
let probeRuns = 0;
|
||||
let release: (result: NodeIdentityProbeResult) => void = () => {};
|
||||
const gate = new Promise<NodeIdentityProbeResult>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const probe = () => {
|
||||
probeRuns += 1;
|
||||
return gate;
|
||||
};
|
||||
const started = startNodePairingSshVerify({
|
||||
plan: makePlan(),
|
||||
expectedDeviceId: identity.deviceId,
|
||||
expectedPublicKey: identity.publicKey,
|
||||
probe,
|
||||
});
|
||||
expect(started).toMatchObject({ alreadyInFlight: false });
|
||||
const retry = startNodePairingSshVerify({
|
||||
plan: makePlan(),
|
||||
expectedDeviceId: identity.deviceId,
|
||||
expectedPublicKey: identity.publicKey,
|
||||
probe,
|
||||
});
|
||||
// The retry must observe the running probe (so the client keeps retrying)
|
||||
// without spawning a second one.
|
||||
expect(retry).toMatchObject({ alreadyInFlight: true });
|
||||
expect(retry?.done).toBe(started?.done);
|
||||
release({
|
||||
status: "ok",
|
||||
stdout: `{"deviceId":"${identity.deviceId}","publicKey":"${identity.publicKey}"}\n`,
|
||||
});
|
||||
await expect(started?.done).resolves.toMatchObject({ ok: true });
|
||||
expect(probeRuns).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
// SSH-verified node pairing auto-approval.
|
||||
// Proves a pending first-time node pairing by reading the device identity back
|
||||
// over SSH from the connecting host and comparing key material. Approval binds
|
||||
// to machine ownership (host key + authorized key + on-disk device identity),
|
||||
// not network locality, so NAT co-tenants, other users on a shared host, and
|
||||
// LAN spoofing all fall through to the manual prompt.
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import type { GatewayNodePairingConfig } from "../config/types.gateway.js";
|
||||
import { normalizeDevicePublicKeyBase64Url } from "../infra/device-identity.js";
|
||||
import { isLoopbackAddress, isPrivateOrLoopbackAddress, isTrustedProxyAddress } from "./net.js";
|
||||
import {
|
||||
isEligibleFreshNodePairingRequest,
|
||||
type FreshNodePairingEligibilityParams,
|
||||
} from "./node-pairing-auto-approve.js";
|
||||
import {
|
||||
runNodeIdentityProbe,
|
||||
type NodeIdentityProbeParams,
|
||||
type NodeIdentityProbeResult,
|
||||
} from "./node-pairing-ssh-verify.runtime.js";
|
||||
|
||||
// Object form of the sshVerify config, derived from the config field so this
|
||||
// module adds no new public config export to the plugin-SDK surface.
|
||||
type SshVerifyConfigObject = Exclude<NonNullable<GatewayNodePairingConfig["sshVerify"]>, boolean>;
|
||||
|
||||
export type NodePairingSshVerifyPolicy = {
|
||||
user: string;
|
||||
identity?: string;
|
||||
timeoutMs: number;
|
||||
cidrs?: string[];
|
||||
};
|
||||
|
||||
export type NodePairingSshVerifyPlan = {
|
||||
policy: NodePairingSshVerifyPolicy;
|
||||
/** Normalized SSH target address (IPv4-mapped prefixes stripped). */
|
||||
host: string;
|
||||
};
|
||||
|
||||
export type NodePairingSshVerifyOutcome =
|
||||
| { ok: true; user: string; host: string }
|
||||
| { ok: false; reason: "probe-failed" | "identity-unreadable" | "identity-mismatch" };
|
||||
|
||||
type NodeIdentityProbe = (params: NodeIdentityProbeParams) => Promise<NodeIdentityProbeResult>;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 7_000;
|
||||
const FAILURE_COOLDOWN_MS = 60_000;
|
||||
// A host that answered with a different key is a definitive negative (and a
|
||||
// possible impersonation attempt); keep it off the probe path for longer.
|
||||
const MISMATCH_COOLDOWN_MS = 5 * 60_000;
|
||||
const MAX_CONCURRENT_PROBES = 4;
|
||||
const MAX_COOLDOWN_ENTRIES = 512;
|
||||
|
||||
function resolveProcessUser(): string | undefined {
|
||||
try {
|
||||
const user = os.userInfo().username.trim();
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to env-based resolution below.
|
||||
}
|
||||
const envUser = (process.env.USER ?? process.env.USERNAME)?.trim();
|
||||
return envUser || undefined;
|
||||
}
|
||||
|
||||
/** Normalize the enabled-by-default config union into a probe policy, or null when off. */
|
||||
export function resolveNodePairingSshVerifyPolicy(
|
||||
raw: boolean | SshVerifyConfigObject | undefined,
|
||||
): NodePairingSshVerifyPolicy | null {
|
||||
if (raw === false) {
|
||||
return null;
|
||||
}
|
||||
const cfg = typeof raw === "object" && raw !== null ? raw : {};
|
||||
const user = cfg.user?.trim() || resolveProcessUser();
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const cidrs = cfg.cidrs?.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
return {
|
||||
user,
|
||||
identity: cfg.identity?.trim() || undefined,
|
||||
timeoutMs:
|
||||
typeof cfg.timeoutMs === "number" && Number.isFinite(cfg.timeoutMs) && cfg.timeoutMs > 0
|
||||
? cfg.timeoutMs
|
||||
: DEFAULT_TIMEOUT_MS,
|
||||
cidrs: cidrs && cidrs.length > 0 ? cidrs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProbeHost(reportedClientIp: string): string | null {
|
||||
const trimmed = reportedClientIp.trim();
|
||||
const host = trimmed.toLowerCase().startsWith("::ffff:") ? trimmed.slice(7) : trimmed;
|
||||
// Zone-scoped IPv6 literals (fe80::1%en0) are not portable SSH targets.
|
||||
if (host.includes("%") || net.isIP(host) === 0) {
|
||||
return null;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether this pairing request qualifies for an SSH verification probe.
|
||||
* Shares the fresh-node eligibility floor with trusted-CIDR auto-approval and
|
||||
* additionally bounds the probe target: default private/CGNAT ranges only, so
|
||||
* a token holder cannot use the gateway as an SSH probe primitive against
|
||||
* arbitrary public addresses.
|
||||
*/
|
||||
export function planNodePairingSshVerify(params: {
|
||||
config: boolean | SshVerifyConfigObject | undefined;
|
||||
eligibility: FreshNodePairingEligibilityParams;
|
||||
}): NodePairingSshVerifyPlan | null {
|
||||
const policy = resolveNodePairingSshVerifyPolicy(params.config);
|
||||
if (!policy) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isEligibleFreshNodePairingRequest(params.eligibility) ||
|
||||
!params.eligibility.reportedClientIp
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Loopback is excluded from the default scope: same-host nodes already pair
|
||||
// silently, and an SSH probe back to the gateway itself proves nothing.
|
||||
const inProbeScope = policy.cidrs
|
||||
? isTrustedProxyAddress(params.eligibility.reportedClientIp, policy.cidrs)
|
||||
: isPrivateOrLoopbackAddress(params.eligibility.reportedClientIp) &&
|
||||
!isLoopbackAddress(params.eligibility.reportedClientIp);
|
||||
if (!inProbeScope) {
|
||||
return null;
|
||||
}
|
||||
const host = normalizeProbeHost(params.eligibility.reportedClientIp);
|
||||
return host ? { policy, host } : null;
|
||||
}
|
||||
|
||||
type ParsedRemoteIdentity = { deviceId: string; publicKey: string };
|
||||
|
||||
// `sh -l` may print profile/motd noise around the JSON payload; scan for the
|
||||
// first line that parses into the expected identity shape.
|
||||
function parseRemoteIdentity(stdout: string): ParsedRemoteIdentity | null {
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as { deviceId?: unknown; publicKey?: unknown };
|
||||
if (typeof parsed.deviceId === "string" && typeof parsed.publicKey === "string") {
|
||||
return { deviceId: parsed.deviceId.trim(), publicKey: parsed.publicKey.trim() };
|
||||
}
|
||||
} catch {
|
||||
// Not the identity line; keep scanning.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Probe bookkeeping is process-local and bounded: single-flight per
|
||||
// host+device, short cooldown after failures so reconnect loops do not
|
||||
// re-probe every few seconds, and a small global concurrency cap so a token
|
||||
// holder cannot fan out SSH probes through the gateway.
|
||||
const inFlightByKey = new Map<string, Promise<NodePairingSshVerifyOutcome>>();
|
||||
const cooldownExpiryByKey = new Map<string, number>();
|
||||
|
||||
function probeKey(host: string, deviceId: string): string {
|
||||
return `${host}\0${deviceId}`;
|
||||
}
|
||||
|
||||
function pruneCooldowns(nowMs: number) {
|
||||
for (const [key, expiry] of cooldownExpiryByKey) {
|
||||
if (expiry <= nowMs) {
|
||||
cooldownExpiryByKey.delete(key);
|
||||
}
|
||||
}
|
||||
while (cooldownExpiryByKey.size > MAX_COOLDOWN_ENTRIES) {
|
||||
const oldest = cooldownExpiryByKey.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
cooldownExpiryByKey.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only reset for the process-local probe bookkeeping. */
|
||||
export function resetNodePairingSshVerifyStateForTests() {
|
||||
inFlightByKey.clear();
|
||||
cooldownExpiryByKey.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an SSH verification for one pending pairing request. Returns null when
|
||||
* the probe should not run right now (cooldown or concurrency cap) so callers
|
||||
* keep the default manual-approval reconnect behavior. A reconnect that lands
|
||||
* while the probe is still running gets `alreadyInFlight: true`: the client
|
||||
* must keep its retry hint, but only the connection that started the probe
|
||||
* owns the approval work.
|
||||
*/
|
||||
export function startNodePairingSshVerify(params: {
|
||||
plan: NodePairingSshVerifyPlan;
|
||||
expectedDeviceId: string;
|
||||
expectedPublicKey: string;
|
||||
probe?: NodeIdentityProbe;
|
||||
nowMs?: number;
|
||||
}): { done: Promise<NodePairingSshVerifyOutcome>; alreadyInFlight: boolean } | null {
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
pruneCooldowns(nowMs);
|
||||
const key = probeKey(params.plan.host, params.expectedDeviceId);
|
||||
const inFlight = inFlightByKey.get(key);
|
||||
if (inFlight) {
|
||||
return { done: inFlight, alreadyInFlight: true };
|
||||
}
|
||||
if ((cooldownExpiryByKey.get(key) ?? 0) > nowMs) {
|
||||
return null;
|
||||
}
|
||||
if (inFlightByKey.size >= MAX_CONCURRENT_PROBES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const probe = params.probe ?? runNodeIdentityProbe;
|
||||
const done = (async (): Promise<NodePairingSshVerifyOutcome> => {
|
||||
const result = await probe({
|
||||
user: params.plan.policy.user,
|
||||
host: params.plan.host,
|
||||
identity: params.plan.policy.identity,
|
||||
timeoutMs: params.plan.policy.timeoutMs,
|
||||
});
|
||||
if (result.status !== "ok") {
|
||||
cooldownExpiryByKey.set(key, Date.now() + FAILURE_COOLDOWN_MS);
|
||||
return { ok: false, reason: "probe-failed" };
|
||||
}
|
||||
const remote = parseRemoteIdentity(result.stdout);
|
||||
if (!remote) {
|
||||
cooldownExpiryByKey.set(key, Date.now() + FAILURE_COOLDOWN_MS);
|
||||
return { ok: false, reason: "identity-unreadable" };
|
||||
}
|
||||
const expectedKey = normalizeDevicePublicKeyBase64Url(params.expectedPublicKey);
|
||||
const remoteKey = normalizeDevicePublicKeyBase64Url(remote.publicKey);
|
||||
const matches =
|
||||
Boolean(expectedKey) &&
|
||||
expectedKey === remoteKey &&
|
||||
remote.deviceId === params.expectedDeviceId;
|
||||
if (!matches) {
|
||||
cooldownExpiryByKey.set(key, Date.now() + MISMATCH_COOLDOWN_MS);
|
||||
return { ok: false, reason: "identity-mismatch" };
|
||||
}
|
||||
return { ok: true, user: params.plan.policy.user, host: params.plan.host };
|
||||
})();
|
||||
|
||||
const tracked = done.finally(() => {
|
||||
inFlightByKey.delete(key);
|
||||
});
|
||||
inFlightByKey.set(key, tracked);
|
||||
return { done: tracked, alreadyInFlight: false };
|
||||
}
|
||||
@@ -1,131 +1,26 @@
|
||||
// Node pairing auto-approve tests cover LAN self-connect detection, token auth,
|
||||
// node identity persistence, and auto-approved pairing state.
|
||||
import net from "node:net";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { writeConfigFile } from "../config/config.js";
|
||||
import { getPairedDevice, listDevicePairing } from "../infra/device-pairing.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { loadDeviceIdentity } from "./device-authz.test-helpers.js";
|
||||
import { pickPrimaryLanIPv4 } from "./net.js";
|
||||
import {
|
||||
connectReq,
|
||||
installGatewayTestHooks,
|
||||
startServer,
|
||||
trackConnectChallengeNonce,
|
||||
} from "./test-helpers.js";
|
||||
import { installGatewayTestHooks } from "./test-helpers.js";
|
||||
import { withLanNodePairingAttempt } from "./test-helpers.lan-pairing.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const TOKEN = "secret";
|
||||
const NODE_CLIENT = {
|
||||
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
version: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
};
|
||||
|
||||
async function openLanGatewayWs(params: { host: string; port: number }): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://${params.host}:${params.port}`, {
|
||||
localAddress: params.host,
|
||||
});
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
ws.off("open", onOpen);
|
||||
ws.off("error", onError);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
ws.once("open", onOpen);
|
||||
ws.once("error", onError);
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function canUseLanSelfConnect(host: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
let client: net.Socket | undefined;
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on("error", () => {});
|
||||
socket.end("ok");
|
||||
});
|
||||
const done = (ok: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
client?.destroy();
|
||||
server.close(() => resolve(ok));
|
||||
};
|
||||
const timer = setTimeout(() => done(false), 1_000);
|
||||
server.once("error", () => done(false));
|
||||
server.listen(0, "0.0.0.0", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
done(false);
|
||||
return;
|
||||
}
|
||||
let sawData = false;
|
||||
client = net.connect({ host, port: address.port, localAddress: host });
|
||||
client.on("data", () => {
|
||||
sawData = true;
|
||||
});
|
||||
client.once("error", () => done(false));
|
||||
client.once("close", () => done(sawData));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function withLanNodePairingAttempt(
|
||||
identityName: string,
|
||||
beforeStart: (lanIp: string) => Promise<void>,
|
||||
run: (params: {
|
||||
res: Awaited<ReturnType<typeof connectReq>>;
|
||||
loaded: ReturnType<typeof loadDeviceIdentity>;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const lanIp = pickPrimaryLanIPv4();
|
||||
if (!lanIp || !(await canUseLanSelfConnect(lanIp))) {
|
||||
return;
|
||||
}
|
||||
await beforeStart(lanIp);
|
||||
const started = await startServer(TOKEN, { bind: "lan", controlUiEnabled: false });
|
||||
let ws: WebSocket | undefined;
|
||||
try {
|
||||
const loaded = loadDeviceIdentity(identityName);
|
||||
ws = await openLanGatewayWs({ host: lanIp, port: started.port });
|
||||
const res = await connectReq(ws, {
|
||||
token: TOKEN,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: NODE_CLIENT,
|
||||
deviceIdentityPath: loaded.identityPath,
|
||||
});
|
||||
await run({ res, loaded });
|
||||
} finally {
|
||||
ws?.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway trusted CIDR node pairing auto-approve", () => {
|
||||
test("stays disabled by default for a direct non-loopback node", async () => {
|
||||
await withLanNodePairingAttempt(
|
||||
"trusted-cidr-default-off",
|
||||
async () => {},
|
||||
async ({ res, loaded }) => {
|
||||
await withLanNodePairingAttempt({
|
||||
identityName: "trusted-cidr-default-off",
|
||||
beforeStart: async () => {
|
||||
// Pin SSH verification off so this case exercises the CIDR default
|
||||
// without spawning a real ssh probe to the runner's own LAN IP.
|
||||
await writeConfigFile({
|
||||
gateway: { nodes: { pairing: { sshVerify: false } } },
|
||||
});
|
||||
},
|
||||
run: async ({ loaded, connectNode }) => {
|
||||
const res = await connectNode();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("pairing required");
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
@@ -135,13 +30,13 @@ describe("gateway trusted CIDR node pairing auto-approve", () => {
|
||||
expect(pending[0]?.silent).toBe(false);
|
||||
expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("auto-approves first-time node pairing from a matching direct non-loopback CIDR", async () => {
|
||||
await withLanNodePairingAttempt(
|
||||
"trusted-cidr-direct-lan-auto-approve",
|
||||
async (lanIp) => {
|
||||
await withLanNodePairingAttempt({
|
||||
identityName: "trusted-cidr-direct-lan-auto-approve",
|
||||
beforeStart: async (lanIp) => {
|
||||
await writeConfigFile({
|
||||
gateway: {
|
||||
nodes: {
|
||||
@@ -152,7 +47,8 @@ describe("gateway trusted CIDR node pairing auto-approve", () => {
|
||||
},
|
||||
});
|
||||
},
|
||||
async ({ res, loaded }) => {
|
||||
run: async ({ loaded, connectNode }) => {
|
||||
const res = await connectNode();
|
||||
expect(res.ok).toBe(true);
|
||||
expect((res.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok");
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
@@ -163,6 +59,6 @@ describe("gateway trusted CIDR node pairing auto-approve", () => {
|
||||
expect(paired?.role).toBe("node");
|
||||
expect(paired?.approvedScopes ?? []).toStrictEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// SSH-verified node pairing e2e: real gateway server on the LAN self-connect
|
||||
// harness, with the SSH probe runtime mocked at the module boundary.
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { writeConfigFile } from "../config/config.js";
|
||||
import {
|
||||
getPairedDevice,
|
||||
listDevicePairing,
|
||||
requestDevicePairing,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { resetNodePairingSshVerifyStateForTests } from "./node-pairing-ssh-verify.js";
|
||||
import type {
|
||||
NodeIdentityProbeParams,
|
||||
NodeIdentityProbeResult,
|
||||
} from "./node-pairing-ssh-verify.runtime.js";
|
||||
import { installGatewayTestHooks } from "./test-helpers.js";
|
||||
import { withLanNodePairingAttempt } from "./test-helpers.lan-pairing.js";
|
||||
|
||||
const probeMock = vi.hoisted(() =>
|
||||
vi.fn<(params: NodeIdentityProbeParams) => Promise<NodeIdentityProbeResult>>(),
|
||||
);
|
||||
|
||||
vi.mock("./node-pairing-ssh-verify.runtime.js", () => ({
|
||||
runNodeIdentityProbe: (params: NodeIdentityProbeParams) => probeMock(params),
|
||||
}));
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
async function waitFor<T>(
|
||||
fn: () => Promise<T | null | undefined>,
|
||||
what: string,
|
||||
timeoutMs = 8_000,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const value = await fn();
|
||||
if (value !== null && value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
type PairingRequiredDetails = {
|
||||
code?: string;
|
||||
recommendedNextStep?: string;
|
||||
pauseReconnect?: boolean;
|
||||
};
|
||||
|
||||
describe("gateway ssh-verified node pairing auto-approve", () => {
|
||||
beforeEach(() => {
|
||||
probeMock.mockReset();
|
||||
resetNodePairingSshVerifyStateForTests();
|
||||
});
|
||||
|
||||
test("approves device pairing and the first capability surface on a key match", async () => {
|
||||
await withLanNodePairingAttempt({
|
||||
identityName: "ssh-verify-key-match",
|
||||
run: async ({ lanIp, loaded, connectNode }) => {
|
||||
probeMock.mockImplementation(async () => ({
|
||||
status: "ok",
|
||||
stdout: `motd noise\n{"deviceId":"${loaded.identity.deviceId}","publicKey":"${loaded.publicKey}"}\n`,
|
||||
}));
|
||||
|
||||
const first = await connectNode();
|
||||
expect(first.ok).toBe(false);
|
||||
const details = first.error?.details as PairingRequiredDetails | undefined;
|
||||
// The node must keep retrying while the detached probe can still land.
|
||||
expect(details?.recommendedNextStep).toBe("wait_then_retry");
|
||||
expect(details?.pauseReconnect).toBe(false);
|
||||
|
||||
const paired = await waitFor(async () => {
|
||||
const record = await getPairedDevice(loaded.identity.deviceId);
|
||||
// Wait for the ssh-verified provenance specifically: the approval
|
||||
// and its read-back must survive the SQLite round-trip.
|
||||
return record?.approvedVia === "ssh-verified" ? record : null;
|
||||
}, "ssh-verified device approval");
|
||||
expect(paired.approvedVia).toBe("ssh-verified");
|
||||
expect(paired.publicKey).toBe(loaded.publicKey);
|
||||
expect(probeMock).toHaveBeenCalledWith(expect.objectContaining({ host: lanIp }));
|
||||
|
||||
const second = await connectNode();
|
||||
expect(second.ok).toBe(true);
|
||||
expect((second.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok");
|
||||
|
||||
// The first capability surface rides on the same machine-ownership
|
||||
// proof: approved without a node.pair prompt.
|
||||
const record = await getPairedDevice(loaded.identity.deviceId);
|
||||
expect(record?.nodeSurface).toBeDefined();
|
||||
expect(record?.pendingNodeSurface).toBeUndefined();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not ssh-approve a pending request that carries scopes from an earlier attempt", async () => {
|
||||
await withLanNodePairingAttempt({
|
||||
identityName: "ssh-verify-scoped-refresh",
|
||||
run: async ({ loaded, connectNode }) => {
|
||||
// Seed a scoped pending request (as an earlier interactive attempt
|
||||
// would). The scopeless reconnect below refreshes this same request in
|
||||
// place, so approving it would smuggle the scope past the fresh
|
||||
// scopeless boundary. A matching probe would approve if reached.
|
||||
await requestDevicePairing({
|
||||
deviceId: loaded.identity.deviceId,
|
||||
publicKey: loaded.publicKey,
|
||||
role: "node",
|
||||
roles: ["node"],
|
||||
scopes: ["node.exec"],
|
||||
});
|
||||
probeMock.mockImplementation(async () => ({
|
||||
status: "ok",
|
||||
stdout: `{"deviceId":"${loaded.identity.deviceId}","publicKey":"${loaded.publicKey}"}\n`,
|
||||
}));
|
||||
|
||||
const res = await connectNode();
|
||||
expect(res.ok).toBe(false);
|
||||
|
||||
// The scoped pending request disqualifies ssh-verify entirely: no probe
|
||||
// runs and the device is never auto-approved.
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
expect(probeMock).not.toHaveBeenCalled();
|
||||
expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves the pairing pending when the remote identity does not match", async () => {
|
||||
await withLanNodePairingAttempt({
|
||||
identityName: "ssh-verify-key-mismatch",
|
||||
run: async ({ loaded, connectNode }) => {
|
||||
// A different key than the pending request: assembled from words so the
|
||||
// fixture is not a high-entropy blob (keeps review bundlers happy).
|
||||
const wrongKey = ["not", "the", "expected", "device", "key"].join("-");
|
||||
probeMock.mockImplementation(async () => ({
|
||||
status: "ok",
|
||||
stdout: `{"deviceId":"${loaded.identity.deviceId}","publicKey":"${wrongKey}"}\n`,
|
||||
}));
|
||||
|
||||
const res = await connectNode();
|
||||
expect(res.ok).toBe(false);
|
||||
|
||||
await waitFor(
|
||||
async () => (probeMock.mock.calls.length > 0 ? true : undefined),
|
||||
"probe execution",
|
||||
);
|
||||
// Give the detached approval path time to (incorrectly) land before
|
||||
// asserting it did not.
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull();
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === loaded.identity.deviceId,
|
||||
);
|
||||
expect(pending).toHaveLength(1);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("sshVerify: false disables the probe and keeps default reconnect pause behavior", async () => {
|
||||
await withLanNodePairingAttempt({
|
||||
identityName: "ssh-verify-disabled",
|
||||
beforeStart: async () => {
|
||||
await writeConfigFile({
|
||||
gateway: { nodes: { pairing: { sshVerify: false } } },
|
||||
});
|
||||
},
|
||||
run: async ({ loaded, connectNode }) => {
|
||||
const res = await connectNode();
|
||||
expect(res.ok).toBe(false);
|
||||
const details = res.error?.details as PairingRequiredDetails | undefined;
|
||||
expect(details?.recommendedNextStep).toBeUndefined();
|
||||
expect(details?.pauseReconnect).toBeUndefined();
|
||||
expect(probeMock).not.toHaveBeenCalled();
|
||||
expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
runWithDiagnosticTraceContext,
|
||||
} from "../../../infra/diagnostic-trace-context.js";
|
||||
import {
|
||||
approveNodePairing,
|
||||
beginNodePairingConnect,
|
||||
finalizeNodePairingCleanupClaim,
|
||||
releaseNodePairingCleanupClaim,
|
||||
@@ -121,7 +122,7 @@ import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js"
|
||||
import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js";
|
||||
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
|
||||
import { pruneSupersededSilentPairingsAfterApproval } from "../../device-pairing-prune.js";
|
||||
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../method-scopes.js";
|
||||
import { ADMIN_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "../../method-scopes.js";
|
||||
import type { GatewayMethodRegistry } from "../../methods/registry.js";
|
||||
import {
|
||||
isLocalishHost,
|
||||
@@ -135,6 +136,10 @@ import {
|
||||
resolveNodePairingClientIpSource,
|
||||
shouldAutoApproveNodePairingFromTrustedCidrs,
|
||||
} from "../../node-pairing-auto-approve.js";
|
||||
import {
|
||||
planNodePairingSshVerify,
|
||||
startNodePairingSshVerify,
|
||||
} from "../../node-pairing-ssh-verify.js";
|
||||
import type { NodeReapprovalCoordinator } from "../../node-reapproval-coordinator.js";
|
||||
import { isOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js";
|
||||
import { checkBrowserOrigin } from "../../origin-check.js";
|
||||
@@ -1629,6 +1634,97 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
} else if (pairing.created) {
|
||||
context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
|
||||
}
|
||||
// SSH-verified auto-approval: for eligible fresh node pairings the
|
||||
// gateway reads the device identity back over SSH from the
|
||||
// connecting host and approves on a key match. Runs detached: this
|
||||
// connection still closes with pairing-required, and the node's
|
||||
// retry loop picks up the approval.
|
||||
let sshVerifyStarted = 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.
|
||||
const pendingReq = pairing.request;
|
||||
const pendingIsFreshScopelessNode =
|
||||
(pendingReq.scopes ?? []).length === 0 &&
|
||||
(pendingReq.role === undefined || pendingReq.role === "node") &&
|
||||
(pendingReq.roles ?? []).every((pendingRole) => pendingRole === "node");
|
||||
if (pairing.request.silent !== true && pendingIsFreshScopelessNode) {
|
||||
const sshVerifyPlan = planNodePairingSshVerify({
|
||||
config: configSnapshot.gateway?.nodes?.pairing?.sshVerify,
|
||||
eligibility: {
|
||||
existingPairedDevice: Boolean(existingPairedDevice),
|
||||
role,
|
||||
reason,
|
||||
scopes,
|
||||
hasBrowserOriginHeader,
|
||||
isControlUi,
|
||||
isWebchat,
|
||||
reportedClientIpSource,
|
||||
reportedClientIp,
|
||||
},
|
||||
});
|
||||
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) {
|
||||
sshVerifyStarted = true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Approving the pending requestId keeps this race-safe: a
|
||||
// superseded or owner-resolved request simply returns null.
|
||||
const approvedBySsh = await approveDevicePairing(pendingRequestId, {
|
||||
callerScopes: scopes,
|
||||
accessMetadata: clientAccessMetadata,
|
||||
approvedVia: "ssh-verified",
|
||||
});
|
||||
if (approvedBySsh?.status !== "approved") {
|
||||
logGateway.info(
|
||||
`node pairing ssh-verify approval skipped device=${device.id} (request superseded or already resolved)`,
|
||||
);
|
||||
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)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Re-resolve: another connection may have superseded/approved the request since we created it
|
||||
recoveryRequestId = await resolveLivePendingRequestId();
|
||||
if (
|
||||
@@ -1650,10 +1746,15 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
role === "node" &&
|
||||
scopes.length === 0 &&
|
||||
!existingPairedDevice;
|
||||
// Keep the node retrying while a detached approval can still land
|
||||
// (bootstrap redemption or a running ssh-verify probe); default
|
||||
// pairing-required behavior pauses the client reconnect loop.
|
||||
const retryWhileDetachedApprovalPending =
|
||||
retryAfterBootstrapPairingApproval || sshVerifyStarted;
|
||||
const pairingErrorDetails = buildPairingConnectErrorDetails({
|
||||
reason,
|
||||
requestId: recoveryRequestId,
|
||||
...(retryAfterBootstrapPairingApproval
|
||||
...(retryWhileDetachedApprovalPending
|
||||
? {
|
||||
recommendedNextStep: "wait_then_retry",
|
||||
retryable: true,
|
||||
@@ -1909,6 +2010,24 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
const nodePairingSnapshot = await beginNodePairingConnect(nodeId);
|
||||
const pairedNode = nodePairingSnapshot.pairedNode;
|
||||
pendingNodePairingCleanup = nodePairingSnapshot.cleanupClaim;
|
||||
// Re-read the device record: how device pairing was approved decides
|
||||
// whether the first capability surface may be marked silent.
|
||||
const pairedDeviceForSurface =
|
||||
device && devicePublicKey ? await getPairedDevice(device.id) : null;
|
||||
const deviceApprovedVia =
|
||||
pairedDeviceForSurface?.publicKey === devicePublicKey
|
||||
? pairedDeviceForSurface?.approvedVia
|
||||
: undefined;
|
||||
// Only device approvals that carry a proof stronger than network
|
||||
// origin may hint silent capability approval: "silent" (same-host
|
||||
// local), "ssh-verified" (device-key match over SSH), "bootstrap"
|
||||
// (owner setup code). "trusted-cidr" proves only that the device came
|
||||
// from an allowed network, which must not silently approve its
|
||||
// command/capability surface.
|
||||
const deviceApprovedNonInteractively =
|
||||
deviceApprovedVia === "silent" ||
|
||||
deviceApprovedVia === "ssh-verified" ||
|
||||
deviceApprovedVia === "bootstrap";
|
||||
let reconciliation: Awaited<ReturnType<typeof reconcileNodePairingOnConnect>>;
|
||||
try {
|
||||
reconciliation = await reconcileNodePairingOnConnect({
|
||||
@@ -1916,6 +2035,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
connectParams,
|
||||
pairedNode,
|
||||
reportedClientIp,
|
||||
initialSurfaceSilent: deviceApprovedNonInteractively,
|
||||
requestPairing: async (input) => {
|
||||
return await requestNodePairingFromConnect({
|
||||
input,
|
||||
@@ -1940,6 +2060,43 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// The ssh-verify key match already proved this node runs under the
|
||||
// operator's account on a machine they own, which is the same claim
|
||||
// a manual capability approval asserts; approve the first declared
|
||||
// surface directly. Surface upgrades still prompt.
|
||||
if (
|
||||
deviceApprovedVia === "ssh-verified" &&
|
||||
!pairedNode &&
|
||||
reconciliation.pendingPairing
|
||||
) {
|
||||
const surfaceRequestId = reconciliation.pendingPairing.request.requestId;
|
||||
const approvedSurface = await approveNodePairing(surfaceRequestId, {
|
||||
callerScopes: [ADMIN_SCOPE, PAIRING_SCOPE, WRITE_SCOPE],
|
||||
});
|
||||
if (approvedSurface && "node" in approvedSurface) {
|
||||
logGateway.info(
|
||||
`security audit: node capability surface ssh-verified auto-approve node=${reconciliation.nodeId} commands=${reconciliation.declaredCommands.join(",") || "<none>"}`,
|
||||
);
|
||||
buildRequestContext().broadcast(
|
||||
"node.pair.resolved",
|
||||
{
|
||||
requestId: surfaceRequestId,
|
||||
nodeId: reconciliation.nodeId,
|
||||
decision: "approved",
|
||||
ts: Date.now(),
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
reconciliation = {
|
||||
...reconciliation,
|
||||
effectiveCaps: reconciliation.declaredCaps,
|
||||
effectiveCommands: reconciliation.declaredCommands,
|
||||
effectivePermissions: reconciliation.declaredPermissions,
|
||||
pendingPairing: undefined,
|
||||
shouldClearPendingPairings: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!reconciliation.shouldClearPendingPairings) {
|
||||
await releasePendingNodePairingCleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Shared LAN self-connect harness for node pairing auto-approval suites.
|
||||
// Tests bind the gateway to the primary LAN IPv4 and connect from that same
|
||||
// address so the server observes a direct non-loopback client IP. Skips
|
||||
// silently on hosts without a usable LAN interface.
|
||||
import net from "node:net";
|
||||
import { WebSocket } from "ws";
|
||||
import { getPairedDevice, removePairedDevice } from "../infra/device-pairing.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { loadDeviceIdentity } from "./device-authz.test-helpers.js";
|
||||
import { pickPrimaryLanIPv4 } from "./net.js";
|
||||
import { connectReq, startServer, trackConnectChallengeNonce } from "./test-helpers.js";
|
||||
|
||||
export const LAN_NODE_PAIRING_TOKEN = "secret";
|
||||
|
||||
const NODE_CLIENT = {
|
||||
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
version: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
};
|
||||
|
||||
async function openLanGatewayWs(params: { host: string; port: number }): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://${params.host}:${params.port}`, {
|
||||
localAddress: params.host,
|
||||
});
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
ws.off("open", onOpen);
|
||||
ws.off("error", onError);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
ws.once("open", onOpen);
|
||||
ws.once("error", onError);
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function canUseLanSelfConnect(host: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
let client: net.Socket | undefined;
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on("error", () => {});
|
||||
socket.end("ok");
|
||||
});
|
||||
const done = (ok: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
client?.destroy();
|
||||
server.close(() => resolve(ok));
|
||||
};
|
||||
const timer = setTimeout(() => done(false), 1_000);
|
||||
server.once("error", () => done(false));
|
||||
server.listen(0, "0.0.0.0", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
done(false);
|
||||
return;
|
||||
}
|
||||
let sawData = false;
|
||||
client = net.connect({ host, port: address.port, localAddress: host });
|
||||
client.on("data", () => {
|
||||
sawData = true;
|
||||
});
|
||||
client.once("error", () => done(false));
|
||||
client.once("close", () => done(sawData));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type LanNodePairingContext = {
|
||||
lanIp: string;
|
||||
loaded: ReturnType<typeof loadDeviceIdentity>;
|
||||
/** Open a fresh LAN WebSocket, run the node connect handshake, close it. */
|
||||
connectNode: () => Promise<Awaited<ReturnType<typeof connectReq>>>;
|
||||
};
|
||||
|
||||
export async function withLanNodePairingAttempt(params: {
|
||||
identityName: string;
|
||||
beforeStart?: (lanIp: string) => Promise<void>;
|
||||
run: (ctx: LanNodePairingContext) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const lanIp = pickPrimaryLanIPv4();
|
||||
if (!lanIp || !(await canUseLanSelfConnect(lanIp))) {
|
||||
return;
|
||||
}
|
||||
await params.beforeStart?.(lanIp);
|
||||
const started = await startServer(LAN_NODE_PAIRING_TOKEN, {
|
||||
bind: "lan",
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
const openSockets: WebSocket[] = [];
|
||||
try {
|
||||
const loaded = loadDeviceIdentity(params.identityName);
|
||||
// The suite shares one state home under --isolate=false; drop any paired
|
||||
// record for this identity so a sibling suite's row cannot mask a fresh
|
||||
// pairing (a stale approvedVia would survive a re-approve by design).
|
||||
if (await getPairedDevice(loaded.identity.deviceId)) {
|
||||
await removePairedDevice(loaded.identity.deviceId);
|
||||
}
|
||||
const connectNode = async () => {
|
||||
const ws = await openLanGatewayWs({ host: lanIp, port: started.port });
|
||||
openSockets.push(ws);
|
||||
try {
|
||||
return await connectReq(ws, {
|
||||
token: LAN_NODE_PAIRING_TOKEN,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: NODE_CLIENT,
|
||||
deviceIdentityPath: loaded.identityPath,
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
await params.run({ lanIp, loaded, connectNode });
|
||||
} finally {
|
||||
for (const ws of openSockets) {
|
||||
ws.close();
|
||||
}
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,19 @@ export function resolveDevicePairingStateDbOptions(baseDir?: string): OpenClawSt
|
||||
return baseDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: baseDir } } : {};
|
||||
}
|
||||
|
||||
const APPROVAL_KINDS: readonly PairedDeviceApprovalKind[] = [
|
||||
"owner",
|
||||
"silent",
|
||||
"trusted-cidr",
|
||||
"bootstrap",
|
||||
];
|
||||
// Read-back allowlist for the approved_via column. The Record type forces
|
||||
// every PairedDeviceApprovalKind to appear here at compile time: omit one and
|
||||
// this object is a type error, instead of the stored provenance silently
|
||||
// dropping to undefined on load (which mergeApprovalKind treats as a legacy
|
||||
// record). Keep this in sync when adding an approval kind.
|
||||
const APPROVAL_KIND_MEMBERS = {
|
||||
owner: true,
|
||||
silent: true,
|
||||
"trusted-cidr": true,
|
||||
"ssh-verified": true,
|
||||
bootstrap: true,
|
||||
} satisfies Record<PairedDeviceApprovalKind, true>;
|
||||
const APPROVAL_KINDS = new Set(Object.keys(APPROVAL_KIND_MEMBERS));
|
||||
|
||||
function toJsonColumn(value: unknown): string | null {
|
||||
return value === undefined ? null : JSON.stringify(value);
|
||||
@@ -134,9 +141,7 @@ function toPairedRow(device: PairedDevice): DevicePairingPaired {
|
||||
}
|
||||
|
||||
function fromApprovedViaColumn(value: string | null): PairedDeviceApprovalKind | null {
|
||||
return (APPROVAL_KINDS as readonly string[]).includes(value ?? "")
|
||||
? (value as PairedDeviceApprovalKind)
|
||||
: null;
|
||||
return value !== null && APPROVAL_KINDS.has(value) ? (value as PairedDeviceApprovalKind) : null;
|
||||
}
|
||||
|
||||
function fromPairedRow(row: DevicePairingPaired): PairedDevice {
|
||||
|
||||
@@ -777,7 +777,10 @@ export async function approveDevicePairing(
|
||||
options: {
|
||||
callerScopes?: readonly string[];
|
||||
accessMetadata?: DevicePairingAccessMetadata;
|
||||
approvedVia?: Extract<PairedDeviceApprovalKind, "owner" | "silent" | "trusted-cidr">;
|
||||
approvedVia?: Extract<
|
||||
PairedDeviceApprovalKind,
|
||||
"owner" | "silent" | "trusted-cidr" | "ssh-verified"
|
||||
>;
|
||||
},
|
||||
baseDir?: string,
|
||||
): Promise<ApproveDevicePairingResult>;
|
||||
@@ -787,7 +790,10 @@ export async function approveDevicePairing(
|
||||
| {
|
||||
callerScopes?: readonly string[];
|
||||
accessMetadata?: DevicePairingAccessMetadata;
|
||||
approvedVia?: Extract<PairedDeviceApprovalKind, "owner" | "silent" | "trusted-cidr">;
|
||||
approvedVia?: Extract<
|
||||
PairedDeviceApprovalKind,
|
||||
"owner" | "silent" | "trusted-cidr" | "ssh-verified"
|
||||
>;
|
||||
}
|
||||
| string,
|
||||
maybeBaseDir?: string,
|
||||
|
||||
@@ -50,11 +50,17 @@ export type DeviceAuthToken = {
|
||||
* How the latest pairing approval was granted. "silent" is a same-host local
|
||||
* policy approval and the only prune-eligible kind: local clients re-pair
|
||||
* silently and cannot collide with another machine's records. "trusted-cidr"
|
||||
* is also non-interactive but crosses hosts, so it is never pruned
|
||||
* automatically (display metadata is not a machine identity). "owner" and
|
||||
* "bootstrap" approvals required a user action and are never pruned.
|
||||
* and "ssh-verified" are also non-interactive but cross hosts, so they are
|
||||
* never pruned automatically (display metadata is not a machine identity).
|
||||
* "owner" and "bootstrap" approvals required a user action and are never
|
||||
* pruned.
|
||||
*/
|
||||
export type PairedDeviceApprovalKind = "owner" | "silent" | "trusted-cidr" | "bootstrap";
|
||||
export type PairedDeviceApprovalKind =
|
||||
| "owner"
|
||||
| "silent"
|
||||
| "trusted-cidr"
|
||||
| "ssh-verified"
|
||||
| "bootstrap";
|
||||
|
||||
/**
|
||||
* Approved node capability surface for a node-role device. Device pairing
|
||||
|
||||
Reference in New Issue
Block a user