From baf4119ae3748a91b68f85555d538cefd0641f27 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 15:43:15 +0100 Subject: [PATCH] fix: harden local TLS gateway probes (#61935) (thanks @ThanhNguyxn07) --- CHANGELOG.md | 1 + src/commands/gateway-status.test.ts | 39 +++++++++++++++++++++++++++ src/commands/gateway-status.ts | 4 +++ src/commands/gateway-status/output.ts | 8 ++++++ 4 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c8bd00788..0b82ca4e6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - TUI/terminal: restore Kitty keyboard protocol and `modifyOtherKeys` state on TUI exit and fatal CLI crashes so parent shells stop inheriting broken keyboard input after `openclaw tui` exits. (#49130) Thanks @biefan. - Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819. - iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including background-safe reconnects, persisted pending approvals, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman. +- Gateway/status: probe local TLS gateways over `wss://`, forward the local cert fingerprint for self-signed loopback probes, and warn when the local TLS runtime cannot load the configured cert. (#61935) Thanks @ThanhNguyxn07. ## 2026.4.5 diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index c685a26cf9b..9b83cb2c630 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -664,6 +664,45 @@ describe("gateway-status command", () => { ); }); + it("warns when local TLS is enabled but the certificate fingerprint cannot be loaded", async () => { + const { runtime, runtimeLogs } = createRuntimeCapture(); + probeGateway.mockClear(); + loadGatewayTlsRuntime.mockResolvedValueOnce({ + enabled: false, + required: true, + error: "gateway tls: cert/key missing", + }); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "local", + tls: { enabled: true }, + auth: { mode: "token", token: "ltok" }, + }, + } as never); + + await runGatewayStatus(runtime, { timeout: "15000", json: true }); + + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + url: "wss://127.0.0.1:18789", + tlsFingerprint: undefined, + }), + ); + + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; + }; + expect(parsed.warnings).toContainEqual( + expect.objectContaining({ + code: "local_tls_runtime_unavailable", + targetIds: ["localLoopback"], + }), + ); + expect( + parsed.warnings?.find((warning) => warning.code === "local_tls_runtime_unavailable")?.message, + ).toContain("gateway tls: cert/key missing"); + }); + it("passes the full caller timeout through to local loopback probes", async () => { const { runtime } = createRuntimeCapture(); probeGateway.mockClear(); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index c134d3413c3..1d9a279f6fa 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -122,6 +122,10 @@ export async function gatewayStatusCommand( sshTarget: probePass.sshTarget, sshTunnelStarted: probePass.sshTunnelStarted, sshTunnelError: probePass.sshTunnelError, + localTlsLoadError: + localTlsRuntime && !localTlsRuntime.enabled && localTlsRuntime.required + ? (localTlsRuntime.error ?? "gateway tls is enabled but local TLS runtime could not load") + : null, }); const primary = pickPrimaryProbedTarget(probePass.probed); diff --git a/src/commands/gateway-status/output.ts b/src/commands/gateway-status/output.ts index 6affed01c40..01cd2aad06b 100644 --- a/src/commands/gateway-status/output.ts +++ b/src/commands/gateway-status/output.ts @@ -32,6 +32,7 @@ export function buildGatewayStatusWarnings(params: { sshTarget: string | null; sshTunnelStarted: boolean; sshTunnelError: string | null; + localTlsLoadError?: string | null; }): GatewayStatusWarning[] { const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe)); const degradedScopeLimited = params.probed.filter((entry) => @@ -46,6 +47,13 @@ export function buildGatewayStatusWarnings(params: { : "SSH tunnel failed to start; falling back to direct probes.", }); } + if (params.localTlsLoadError) { + warnings.push({ + code: "local_tls_runtime_unavailable", + message: `Local gateway TLS is enabled but OpenClaw could not load the local certificate fingerprint: ${params.localTlsLoadError}`, + targetIds: ["localLoopback"], + }); + } if (reachable.length > 1) { warnings.push({ code: "multiple_gateways",