mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
* fix(gateway): pass actual version to Control UI client instead of "dev" The GatewayClient, CLI WS client, and browser Control UI all sent "dev" as their clientVersion during handshake, making it impossible to distinguish builds in gateway logs and health snapshots. - GatewayClient and CLI WS client now use the resolved VERSION constant - Control UI reads serverVersion from the bootstrap endpoint and forwards it when connecting - Bootstrap contract extended with serverVersion field Closes #35209 * Gateway: fix control-ui version version-reporting consistency * Control UI: guard deferred bootstrap connect after disconnect * fix(ui): accept same-origin http and relative gateway URLs for client version --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { WebSocket, type ClientOptions, type CertMeta } from "ws";
|
|
import {
|
|
clearDeviceAuthToken,
|
|
loadDeviceAuthToken,
|
|
storeDeviceAuthToken,
|
|
} from "../infra/device-auth-store.js";
|
|
import type { DeviceIdentity } from "../infra/device-identity.js";
|
|
import {
|
|
loadOrCreateDeviceIdentity,
|
|
publicKeyRawBase64UrlFromPem,
|
|
signDevicePayload,
|
|
} from "../infra/device-identity.js";
|
|
import { clearDevicePairing } from "../infra/device-pairing.js";
|
|
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
|
|
import { rawDataToString } from "../infra/ws.js";
|
|
import { logDebug, logError } from "../logger.js";
|
|
import {
|
|
GATEWAY_CLIENT_MODES,
|
|
GATEWAY_CLIENT_NAMES,
|
|
type GatewayClientMode,
|
|
type GatewayClientName,
|
|
} from "../utils/message-channel.js";
|
|
import { VERSION } from "../version.js";
|
|
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
|
|
import { isSecureWebSocketUrl } from "./net.js";
|
|
import {
|
|
type ConnectParams,
|
|
type EventFrame,
|
|
type HelloOk,
|
|
PROTOCOL_VERSION,
|
|
type RequestFrame,
|
|
validateEventFrame,
|
|
validateRequestFrame,
|
|
validateResponseFrame,
|
|
} from "./protocol/index.js";
|
|
|
|
type Pending = {
|
|
resolve: (value: unknown) => void;
|
|
reject: (err: unknown) => void;
|
|
expectFinal: boolean;
|
|
};
|
|
|
|
export type GatewayClientOptions = {
|
|
url?: string; // ws://127.0.0.1:18789
|
|
connectDelayMs?: number;
|
|
tickWatchMinIntervalMs?: number;
|
|
token?: string;
|
|
deviceToken?: string;
|
|
password?: string;
|
|
instanceId?: string;
|
|
clientName?: GatewayClientName;
|
|
clientDisplayName?: string;
|
|
clientVersion?: string;
|
|
platform?: string;
|
|
deviceFamily?: string;
|
|
mode?: GatewayClientMode;
|
|
role?: string;
|
|
scopes?: string[];
|
|
caps?: string[];
|
|
commands?: string[];
|
|
permissions?: Record<string, boolean>;
|
|
pathEnv?: string;
|
|
deviceIdentity?: DeviceIdentity;
|
|
minProtocol?: number;
|
|
maxProtocol?: number;
|
|
tlsFingerprint?: string;
|
|
onEvent?: (evt: EventFrame) => void;
|
|
onHelloOk?: (hello: HelloOk) => void;
|
|
onConnectError?: (err: Error) => void;
|
|
onClose?: (code: number, reason: string) => void;
|
|
onGap?: (info: { expected: number; received: number }) => void;
|
|
};
|
|
|
|
export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> = {
|
|
1000: "normal closure",
|
|
1006: "abnormal closure (no close frame)",
|
|
1008: "policy violation",
|
|
1012: "service restart",
|
|
};
|
|
|
|
export function describeGatewayCloseCode(code: number): string | undefined {
|
|
return GATEWAY_CLOSE_CODE_HINTS[code];
|
|
}
|
|
|
|
export class GatewayClient {
|
|
private ws: WebSocket | null = null;
|
|
private opts: GatewayClientOptions;
|
|
private pending = new Map<string, Pending>();
|
|
private backoffMs = 1000;
|
|
private closed = false;
|
|
private lastSeq: number | null = null;
|
|
private connectNonce: string | null = null;
|
|
private connectSent = false;
|
|
private connectTimer: NodeJS.Timeout | null = null;
|
|
// Track last tick to detect silent stalls.
|
|
private lastTick: number | null = null;
|
|
private tickIntervalMs = 30_000;
|
|
private tickTimer: NodeJS.Timeout | null = null;
|
|
|
|
constructor(opts: GatewayClientOptions) {
|
|
this.opts = {
|
|
...opts,
|
|
deviceIdentity: opts.deviceIdentity ?? loadOrCreateDeviceIdentity(),
|
|
};
|
|
}
|
|
|
|
start() {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
|
if (this.opts.tlsFingerprint && !url.startsWith("wss://")) {
|
|
this.opts.onConnectError?.(new Error("gateway tls fingerprint requires wss:// gateway url"));
|
|
return;
|
|
}
|
|
|
|
const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
|
|
// Security check: block ALL plaintext ws:// to non-loopback addresses (CWE-319, CVSS 9.8)
|
|
// This protects both credentials AND chat/conversation data from MITM attacks.
|
|
// Device tokens may be loaded later in sendConnect(), so we block regardless of hasCredentials.
|
|
if (!isSecureWebSocketUrl(url, { allowPrivateWs })) {
|
|
// Safe hostname extraction - avoid throwing on malformed URLs in error path
|
|
let displayHost = url;
|
|
try {
|
|
displayHost = new URL(url).hostname || url;
|
|
} catch {
|
|
// Use raw URL if parsing fails
|
|
}
|
|
const error = new Error(
|
|
`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
|
|
"Both credentials and chat data would be exposed to network interception. " +
|
|
"Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " +
|
|
"(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " +
|
|
(allowPrivateWs
|
|
? ""
|
|
: "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1. ") +
|
|
"Run `openclaw doctor --fix` for guidance.",
|
|
);
|
|
this.opts.onConnectError?.(error);
|
|
return;
|
|
}
|
|
// Allow node screen snapshots and other large responses.
|
|
const wsOptions: ClientOptions = {
|
|
maxPayload: 25 * 1024 * 1024,
|
|
};
|
|
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
|
wsOptions.rejectUnauthorized = false;
|
|
wsOptions.checkServerIdentity = ((_host: string, cert: CertMeta) => {
|
|
const fingerprintValue =
|
|
typeof cert === "object" && cert && "fingerprint256" in cert
|
|
? ((cert as { fingerprint256?: string }).fingerprint256 ?? "")
|
|
: "";
|
|
const fingerprint = normalizeFingerprint(
|
|
typeof fingerprintValue === "string" ? fingerprintValue : "",
|
|
);
|
|
const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? "");
|
|
if (!expected) {
|
|
return new Error("gateway tls fingerprint missing");
|
|
}
|
|
if (!fingerprint) {
|
|
return new Error("gateway tls fingerprint unavailable");
|
|
}
|
|
if (fingerprint !== expected) {
|
|
return new Error("gateway tls fingerprint mismatch");
|
|
}
|
|
return undefined;
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
}) as any;
|
|
}
|
|
this.ws = new WebSocket(url, wsOptions);
|
|
|
|
this.ws.on("open", () => {
|
|
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
|
const tlsError = this.validateTlsFingerprint();
|
|
if (tlsError) {
|
|
this.opts.onConnectError?.(tlsError);
|
|
this.ws?.close(1008, tlsError.message);
|
|
return;
|
|
}
|
|
}
|
|
this.queueConnect();
|
|
});
|
|
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
|
|
this.ws.on("close", (code, reason) => {
|
|
const reasonText = rawDataToString(reason);
|
|
this.ws = null;
|
|
// Clear persisted device auth state only when device-token auth was active.
|
|
// Shared token/password failures can return the same close reason but should
|
|
// not erase a valid cached device token.
|
|
if (
|
|
code === 1008 &&
|
|
reasonText.toLowerCase().includes("device token mismatch") &&
|
|
!this.opts.token &&
|
|
!this.opts.password &&
|
|
this.opts.deviceIdentity
|
|
) {
|
|
const deviceId = this.opts.deviceIdentity.deviceId;
|
|
const role = this.opts.role ?? "operator";
|
|
try {
|
|
clearDeviceAuthToken({ deviceId, role });
|
|
void clearDevicePairing(deviceId).catch((err) => {
|
|
logDebug(`failed clearing stale device pairing for device ${deviceId}: ${String(err)}`);
|
|
});
|
|
logDebug(`cleared stale device-auth token for device ${deviceId}`);
|
|
} catch (err) {
|
|
logDebug(
|
|
`failed clearing stale device-auth token for device ${deviceId}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
|
|
this.scheduleReconnect();
|
|
this.opts.onClose?.(code, reasonText);
|
|
});
|
|
this.ws.on("error", (err) => {
|
|
logDebug(`gateway client error: ${String(err)}`);
|
|
if (!this.connectSent) {
|
|
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
});
|
|
}
|
|
|
|
stop() {
|
|
this.closed = true;
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
this.ws?.close();
|
|
this.ws = null;
|
|
this.flushPendingErrors(new Error("gateway client stopped"));
|
|
}
|
|
|
|
private sendConnect() {
|
|
if (this.connectSent) {
|
|
return;
|
|
}
|
|
const nonce = this.connectNonce?.trim() ?? "";
|
|
if (!nonce) {
|
|
this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
|
|
this.ws?.close(1008, "connect challenge missing nonce");
|
|
return;
|
|
}
|
|
this.connectSent = true;
|
|
if (this.connectTimer) {
|
|
clearTimeout(this.connectTimer);
|
|
this.connectTimer = null;
|
|
}
|
|
const role = this.opts.role ?? "operator";
|
|
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
|
const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined;
|
|
const storedToken = this.opts.deviceIdentity
|
|
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
|
|
: null;
|
|
// Keep shared gateway credentials explicit. Persisted per-device tokens only
|
|
// participate when no explicit shared token is provided.
|
|
const resolvedDeviceToken =
|
|
explicitDeviceToken ?? (!explicitGatewayToken ? (storedToken ?? undefined) : undefined);
|
|
// Legacy compatibility: keep `auth.token` populated for device-token auth when
|
|
// no explicit shared token is present.
|
|
const authToken = explicitGatewayToken ?? resolvedDeviceToken;
|
|
const authPassword = this.opts.password?.trim() || undefined;
|
|
const auth =
|
|
authToken || authPassword || resolvedDeviceToken
|
|
? {
|
|
token: authToken,
|
|
deviceToken: resolvedDeviceToken,
|
|
password: authPassword,
|
|
}
|
|
: undefined;
|
|
const signedAtMs = Date.now();
|
|
const scopes = this.opts.scopes ?? ["operator.admin"];
|
|
const platform = this.opts.platform ?? process.platform;
|
|
const device = (() => {
|
|
if (!this.opts.deviceIdentity) {
|
|
return undefined;
|
|
}
|
|
const payload = buildDeviceAuthPayloadV3({
|
|
deviceId: this.opts.deviceIdentity.deviceId,
|
|
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
|
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
|
role,
|
|
scopes,
|
|
signedAtMs,
|
|
token: authToken ?? null,
|
|
nonce,
|
|
platform,
|
|
deviceFamily: this.opts.deviceFamily,
|
|
});
|
|
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
|
|
return {
|
|
id: this.opts.deviceIdentity.deviceId,
|
|
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
|
|
signature,
|
|
signedAt: signedAtMs,
|
|
nonce,
|
|
};
|
|
})();
|
|
const params: ConnectParams = {
|
|
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
|
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
|
client: {
|
|
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
|
displayName: this.opts.clientDisplayName,
|
|
version: this.opts.clientVersion ?? VERSION,
|
|
platform,
|
|
deviceFamily: this.opts.deviceFamily,
|
|
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
|
instanceId: this.opts.instanceId,
|
|
},
|
|
caps: Array.isArray(this.opts.caps) ? this.opts.caps : [],
|
|
commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined,
|
|
permissions:
|
|
this.opts.permissions && typeof this.opts.permissions === "object"
|
|
? this.opts.permissions
|
|
: undefined,
|
|
pathEnv: this.opts.pathEnv,
|
|
auth,
|
|
role,
|
|
scopes,
|
|
device,
|
|
};
|
|
|
|
void this.request<HelloOk>("connect", params)
|
|
.then((helloOk) => {
|
|
const authInfo = helloOk?.auth;
|
|
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
|
|
storeDeviceAuthToken({
|
|
deviceId: this.opts.deviceIdentity.deviceId,
|
|
role: authInfo.role ?? role,
|
|
token: authInfo.deviceToken,
|
|
scopes: authInfo.scopes ?? [],
|
|
});
|
|
}
|
|
this.backoffMs = 1000;
|
|
this.tickIntervalMs =
|
|
typeof helloOk.policy?.tickIntervalMs === "number"
|
|
? helloOk.policy.tickIntervalMs
|
|
: 30_000;
|
|
this.lastTick = Date.now();
|
|
this.startTickWatch();
|
|
this.opts.onHelloOk?.(helloOk);
|
|
})
|
|
.catch((err) => {
|
|
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
|
|
const msg = `gateway connect failed: ${String(err)}`;
|
|
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) {
|
|
logDebug(msg);
|
|
} else {
|
|
logError(msg);
|
|
}
|
|
this.ws?.close(1008, "connect failed");
|
|
});
|
|
}
|
|
|
|
private handleMessage(raw: string) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (validateEventFrame(parsed)) {
|
|
const evt = parsed;
|
|
if (evt.event === "connect.challenge") {
|
|
const payload = evt.payload as { nonce?: unknown } | undefined;
|
|
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
|
|
if (!nonce || nonce.trim().length === 0) {
|
|
this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
|
|
this.ws?.close(1008, "connect challenge missing nonce");
|
|
return;
|
|
}
|
|
this.connectNonce = nonce.trim();
|
|
this.sendConnect();
|
|
return;
|
|
}
|
|
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
|
if (seq !== null) {
|
|
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
|
|
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
|
|
}
|
|
this.lastSeq = seq;
|
|
}
|
|
if (evt.event === "tick") {
|
|
this.lastTick = Date.now();
|
|
}
|
|
this.opts.onEvent?.(evt);
|
|
return;
|
|
}
|
|
if (validateResponseFrame(parsed)) {
|
|
const pending = this.pending.get(parsed.id);
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
// If the payload is an ack with status accepted, keep waiting for final.
|
|
const payload = parsed.payload as { status?: unknown } | undefined;
|
|
const status = payload?.status;
|
|
if (pending.expectFinal && status === "accepted") {
|
|
return;
|
|
}
|
|
this.pending.delete(parsed.id);
|
|
if (parsed.ok) {
|
|
pending.resolve(parsed.payload);
|
|
} else {
|
|
pending.reject(new Error(parsed.error?.message ?? "unknown error"));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logDebug(`gateway client parse error: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
private queueConnect() {
|
|
this.connectNonce = null;
|
|
this.connectSent = false;
|
|
const rawConnectDelayMs = this.opts.connectDelayMs;
|
|
const connectChallengeTimeoutMs =
|
|
typeof rawConnectDelayMs === "number" && Number.isFinite(rawConnectDelayMs)
|
|
? Math.max(250, Math.min(10_000, rawConnectDelayMs))
|
|
: 2_000;
|
|
if (this.connectTimer) {
|
|
clearTimeout(this.connectTimer);
|
|
}
|
|
this.connectTimer = setTimeout(() => {
|
|
if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
this.opts.onConnectError?.(new Error("gateway connect challenge timeout"));
|
|
this.ws?.close(1008, "connect challenge timeout");
|
|
}, connectChallengeTimeoutMs);
|
|
}
|
|
|
|
private scheduleReconnect() {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
const delay = this.backoffMs;
|
|
this.backoffMs = Math.min(this.backoffMs * 2, 30_000);
|
|
setTimeout(() => this.start(), delay).unref();
|
|
}
|
|
|
|
private flushPendingErrors(err: Error) {
|
|
for (const [, p] of this.pending) {
|
|
p.reject(err);
|
|
}
|
|
this.pending.clear();
|
|
}
|
|
|
|
private startTickWatch() {
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
}
|
|
const rawMinInterval = this.opts.tickWatchMinIntervalMs;
|
|
const minInterval =
|
|
typeof rawMinInterval === "number" && Number.isFinite(rawMinInterval)
|
|
? Math.max(1, Math.min(30_000, rawMinInterval))
|
|
: 1000;
|
|
const interval = Math.max(this.tickIntervalMs, minInterval);
|
|
this.tickTimer = setInterval(() => {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
if (!this.lastTick) {
|
|
return;
|
|
}
|
|
const gap = Date.now() - this.lastTick;
|
|
if (gap > this.tickIntervalMs * 2) {
|
|
this.ws?.close(4000, "tick timeout");
|
|
}
|
|
}, interval);
|
|
}
|
|
|
|
private validateTlsFingerprint(): Error | null {
|
|
if (!this.opts.tlsFingerprint || !this.ws) {
|
|
return null;
|
|
}
|
|
const expected = normalizeFingerprint(this.opts.tlsFingerprint);
|
|
if (!expected) {
|
|
return new Error("gateway tls fingerprint missing");
|
|
}
|
|
const socket = (
|
|
this.ws as WebSocket & {
|
|
_socket?: { getPeerCertificate?: () => { fingerprint256?: string } };
|
|
}
|
|
)._socket;
|
|
if (!socket || typeof socket.getPeerCertificate !== "function") {
|
|
return new Error("gateway tls fingerprint unavailable");
|
|
}
|
|
const cert = socket.getPeerCertificate();
|
|
const fingerprint = normalizeFingerprint(cert?.fingerprint256 ?? "");
|
|
if (!fingerprint) {
|
|
return new Error("gateway tls fingerprint unavailable");
|
|
}
|
|
if (fingerprint !== expected) {
|
|
return new Error("gateway tls fingerprint mismatch");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async request<T = Record<string, unknown>>(
|
|
method: string,
|
|
params?: unknown,
|
|
opts?: { expectFinal?: boolean },
|
|
): Promise<T> {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
throw new Error("gateway not connected");
|
|
}
|
|
const id = randomUUID();
|
|
const frame: RequestFrame = { type: "req", id, method, params };
|
|
if (!validateRequestFrame(frame)) {
|
|
throw new Error(
|
|
`invalid request frame: ${JSON.stringify(validateRequestFrame.errors, null, 2)}`,
|
|
);
|
|
}
|
|
const expectFinal = opts?.expectFinal === true;
|
|
const p = new Promise<T>((resolve, reject) => {
|
|
this.pending.set(id, {
|
|
resolve: (value) => resolve(value as T),
|
|
reject,
|
|
expectFinal,
|
|
});
|
|
});
|
|
this.ws.send(JSON.stringify(frame));
|
|
return p;
|
|
}
|
|
}
|