From df6c58cf30d9a5d5cacfaf78a9cb0bf15f439397 Mon Sep 17 00:00:00 2001 From: deepkilo Date: Sat, 25 Apr 2026 12:45:15 +0200 Subject: [PATCH] fix(gateway): use secure dashboard links when TLS is enabled (#71499) Fixes #71494. - Render Control UI links with https:// when gateway TLS is enabled. - Render websocket links with wss:// through the shared link resolver. - Add daemon status handoff coverage and TLS scheme docs. Co-authored-by: deepkilord --- CHANGELOG.md | 1 + docs/cli/dashboard.md | 2 + docs/web/dashboard.md | 4 ++ docs/web/index.md | 3 + src/cli/daemon-cli/status.gather.test.ts | 1 + src/cli/daemon-cli/status.gather.ts | 5 +- src/cli/daemon-cli/status.print.test.ts | 58 ++++++++++++++++++- src/cli/daemon-cli/status.print.ts | 1 + src/commands/configure.wizard.ts | 2 + src/commands/dashboard.links.test.ts | 1 + src/commands/dashboard.test.ts | 3 + src/commands/dashboard.ts | 1 + src/commands/onboard-helpers.test.ts | 11 ++++ src/commands/onboard-non-interactive/local.ts | 1 + src/commands/status-all/format.test.ts | 10 ++++ src/commands/status-all/format.ts | 1 + src/gateway/control-ui-links.ts | 7 ++- src/wizard/setup.finalize.ts | 2 + 18 files changed, 110 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f31d0f9bc8..4ff7ef3fedb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/dashboard: render Control UI and WebSocket links with `https://`/`wss://` when `gateway.tls.enabled=true`, including `openclaw gateway status`. Fixes #71494. (#71499) Thanks @deepkilo. - Agents/OpenAI-compatible: default proxy/local completions tool requests to `tool_choice: "auto"` when tools are present, so providers enter native tool-calling mode instead of replying with plain-text tool directives. (#71472) Thanks @Speed-maker. - OpenAI image generation: use `gpt-5.5` for the Codex OAuth responses transport instead of the retired `gpt-5.4` model, fixing 500s from ChatGPT Codex image generation. Fixes #71513. Thanks @baolongl. - Google video generation: download direct MLDev Veo `video.uri` results instead of passing them through the Files API path, fixing 404s after successful generation/polling. Fixes #71200. Thanks @panhaishan. diff --git a/docs/cli/dashboard.md b/docs/cli/dashboard.md index 40ac30851b9..1603156eac6 100644 --- a/docs/cli/dashboard.md +++ b/docs/cli/dashboard.md @@ -18,6 +18,8 @@ openclaw dashboard --no-open Notes: - `dashboard` resolves configured `gateway.auth.token` SecretRefs when possible. +- `dashboard` follows `gateway.tls.enabled`: TLS-enabled gateways print/open + `https://` Control UI URLs and connect over `wss://`. - For SecretRef-managed tokens (resolved or unresolved), `dashboard` prints/copies/opens a non-tokenized URL to avoid exposing external secrets in terminal output, clipboard history, or browser-launch arguments. - If `gateway.auth.token` is SecretRef-managed but unresolved in this command path, the command prints a non-tokenized URL and explicit remediation guidance instead of embedding an invalid token placeholder. diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 5c79fd1f362..403f98f921a 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -11,6 +11,8 @@ The Gateway dashboard is the browser Control UI served at `/` by default Quick open (local Gateway): - [http://127.0.0.1:18789/](http://127.0.0.1:18789/) (or [http://localhost:18789/](http://localhost:18789/)) +- With `gateway.tls.enabled: true`, use `https://127.0.0.1:18789/` and + `wss://127.0.0.1:18789` for the WebSocket endpoint. Key references: @@ -43,6 +45,8 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. ## Auth basics (local vs remote) - **Localhost**: open `http://127.0.0.1:18789/`. +- **Gateway TLS**: when `gateway.tls.enabled: true`, dashboard/status links use + `https://` and Control UI WebSocket links use `wss://`. - **Shared-secret token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment for one-time bootstrap, and the Control UI keeps it in sessionStorage for the diff --git a/docs/web/index.md b/docs/web/index.md index 2e20a8b3ca6..4ead80976f7 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -9,6 +9,7 @@ title: "Web" The Gateway serves a small **browser Control UI** (Vite + Lit) from the same port as the Gateway WebSocket: - default: `http://:18789/` +- with `gateway.tls.enabled: true`: `https://:18789/` - optional prefix: set `gateway.controlUi.basePath` (e.g. `/openclaw`) Capabilities live in [Control UI](/web/control-ui). @@ -100,6 +101,8 @@ Open: gateway token (even on loopback). - In shared-secret mode, the UI sends `connect.params.auth.token` or `connect.params.auth.password`. +- When `gateway.tls.enabled: true`, local dashboard and status helpers render + `https://` dashboard URLs and `wss://` WebSocket URLs. - In identity-bearing modes such as Tailscale Serve or `trusted-proxy`, the WebSocket auth check is satisfied from request headers instead. - For non-loopback Control UI deployments, set `gateway.controlUi.allowedOrigins` diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 96a0b902716..b562f417f2c 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -205,6 +205,7 @@ describe("gatherDaemonStatus", () => { }), ); expect(status.gateway?.probeUrl).toBe("wss://127.0.0.1:19001"); + expect(status.gateway?.tlsEnabled).toBe(true); expect(status.rpc?.url).toBe("wss://127.0.0.1:19001"); expect(status.rpc?.ok).toBe(true); expect(inspectGatewayRestart).not.toHaveBeenCalled(); diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 657c8b81ae8..b695956f5c4 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -43,6 +43,7 @@ type GatewayStatusSummary = { bindMode: GatewayBindMode; bindHost: string; customBindHost?: string; + tlsEnabled?: boolean; port: number; portSource: "service args" | "env/config"; probeUrl: string; @@ -284,7 +285,8 @@ async function resolveGatewayStatusSummary(params: { }); const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost); const probeUrlOverride = trimToUndefined(params.rpcUrlOverride) ?? null; - const scheme = params.daemonCfg.gateway?.tls?.enabled === true ? "wss" : "ws"; + const tlsEnabled = params.daemonCfg.gateway?.tls?.enabled === true; + const scheme = tlsEnabled ? "wss" : "ws"; const probeUrl = probeUrlOverride ?? `${scheme}://${probeHost}:${daemonPort}`; let probeNote = !probeUrlOverride && bindMode === "lan" @@ -300,6 +302,7 @@ async function resolveGatewayStatusSummary(params: { bindMode, bindHost, customBindHost, + ...(tlsEnabled ? { tlsEnabled } : {}), port: daemonPort, portSource, probeUrl, diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index 90d52d0942a..6e6fd5862d9 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -6,6 +6,9 @@ const runtime = vi.hoisted(() => ({ log: vi.fn<(line: string) => void>(), error: vi.fn<(line: string) => void>(), })); +const resolveControlUiLinksMock = vi.hoisted(() => + vi.fn((_opts?: unknown) => ({ httpUrl: "http://127.0.0.1:18789" })), +); vi.mock("../../runtime.js", () => ({ defaultRuntime: runtime, @@ -21,7 +24,7 @@ vi.mock("../../terminal/theme.js", async () => { }); vi.mock("../../gateway/control-ui-links.js", () => ({ - resolveControlUiLinks: () => ({ httpUrl: "http://127.0.0.1:18789" }), + resolveControlUiLinks: resolveControlUiLinksMock, })); vi.mock("../../daemon/inspect.js", () => ({ @@ -73,6 +76,7 @@ describe("printDaemonStatus", () => { beforeEach(() => { runtime.log.mockReset(); runtime.error.mockReset(); + resolveControlUiLinksMock.mockClear(); }); it("prints stale gateway pid guidance when runtime does not own the listener", () => { @@ -152,4 +156,56 @@ describe("printDaemonStatus", () => { expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Connectivity probe: ok")); expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Capability: write-capable")); }); + + it("passes daemon TLS state to dashboard link rendering", () => { + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { status: "running", pid: 8000 }, + }, + config: { + cli: { + path: "/tmp/openclaw-cli/openclaw.json", + exists: true, + valid: true, + }, + daemon: { + path: "/tmp/openclaw-daemon/openclaw.json", + exists: true, + valid: true, + controlUi: { basePath: "/ui" }, + }, + mismatch: true, + }, + gateway: { + bindMode: "lan", + bindHost: "0.0.0.0", + port: 19001, + portSource: "service args", + probeUrl: "wss://127.0.0.1:19001", + tlsEnabled: true, + }, + rpc: { + ok: true, + kind: "connect", + capability: "write_capable", + url: "wss://127.0.0.1:19001", + }, + extraServices: [], + }, + { json: false }, + ); + + expect(resolveControlUiLinksMock).toHaveBeenCalledWith({ + port: 19001, + bind: "lan", + customBindHost: undefined, + basePath: "/ui", + tlsEnabled: true, + }); + }); }); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index a0360211c9d..f29748b53b6 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -165,6 +165,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) bind: status.gateway.bindMode, customBindHost: status.gateway.customBindHost, basePath: status.config?.daemon?.controlUi?.basePath, + tlsEnabled: status.gateway.tlsEnabled === true, }); defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`); } diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 09ca65e0ec0..6c55f3785e6 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -108,6 +108,7 @@ async function runGatewayHealthCheck(params: { port: params.port, customBindHost: params.cfg.gateway?.customBindHost, basePath: undefined, + tlsEnabled: params.cfg.gateway?.tls?.enabled === true, }); const remoteUrl = params.cfg.gateway?.remote?.url?.trim(); const wsUrl = params.cfg.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl; @@ -754,6 +755,7 @@ export async function runConfigureWizard( port: gatewayPort, customBindHost: nextConfig.gateway?.customBindHost, basePath: nextConfig.gateway?.controlUi?.basePath, + tlsEnabled: nextConfig.gateway?.tls?.enabled === true, }); const newPassword = process.env.OPENCLAW_GATEWAY_PASSWORD ?? diff --git a/src/commands/dashboard.links.test.ts b/src/commands/dashboard.links.test.ts index 61fef0ab978..e5a2fb5175c 100644 --- a/src/commands/dashboard.links.test.ts +++ b/src/commands/dashboard.links.test.ts @@ -88,6 +88,7 @@ describe("dashboardCommand", () => { bind: "loopback", customBindHost: undefined, basePath: undefined, + tlsEnabled: false, }); // clipboard and browser still get the full authenticated URL expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123"); diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts index e5c1852ccd0..37fd6f2c2e8 100644 --- a/src/commands/dashboard.test.ts +++ b/src/commands/dashboard.test.ts @@ -85,6 +85,7 @@ describe("dashboardCommand bind selection", () => { bind: "loopback", customBindHost: undefined, basePath: undefined, + tlsEnabled: false, }); }); @@ -98,6 +99,7 @@ describe("dashboardCommand bind selection", () => { bind: "custom", customBindHost: "10.0.0.5", basePath: undefined, + tlsEnabled: false, }); }); @@ -111,6 +113,7 @@ describe("dashboardCommand bind selection", () => { bind: "tailnet", customBindHost: undefined, basePath: undefined, + tlsEnabled: false, }); }); }); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index f261c39b347..429031bd8f8 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -38,6 +38,7 @@ export async function dashboardCommand( bind: bind === "lan" ? "loopback" : bind, customBindHost, basePath, + tlsEnabled: cfg.gateway?.tls?.enabled === true, }); // Avoid embedding externally managed SecretRef tokens in terminal/clipboard/browser args. const includeTokenInUrl = token.length > 0 && !resolvedToken.secretRefConfigured; diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index fb07427e830..1e793f049e2 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -204,6 +204,17 @@ describe("resolveControlUiLinks", () => { expect(links.wsUrl).toBe("ws://192.168.1.100:18789"); }); + it("uses secure schemes when gateway TLS is enabled", () => { + const links = resolveControlUiLinks({ + port: 18789, + bind: "custom", + customBindHost: "192.168.1.100", + tlsEnabled: true, + }); + expect(links.httpUrl).toBe("https://192.168.1.100:18789/"); + expect(links.wsUrl).toBe("wss://192.168.1.100:18789"); + }); + it("falls back to loopback for invalid customBindHost", () => { const links = resolveControlUiLinks({ port: 18789, diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 1120e3bef29..cdcde4c0bad 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -262,6 +262,7 @@ export async function runNonInteractiveLocalSetup(params: { port: gatewayResult.port, customBindHost: nextConfig.gateway?.customBindHost, basePath: undefined, + tlsEnabled: nextConfig.gateway?.tls?.enabled === true, }); const installDaemonGatewayHealthTiming = resolveInstallDaemonGatewayHealthTiming(); const probeAuth = await resolveGatewayHealthProbeToken(nextConfig); diff --git a/src/commands/status-all/format.test.ts b/src/commands/status-all/format.test.ts index 49f872d454f..830a11d32b6 100644 --- a/src/commands/status-all/format.test.ts +++ b/src/commands/status-all/format.test.ts @@ -101,6 +101,16 @@ describe("status-all format", () => { }, }), ).toBe("http://127.0.0.1:18789/ui/"); + expect( + resolveStatusDashboardUrl({ + cfg: { + gateway: { + bind: "loopback", + tls: { enabled: true }, + }, + }, + }), + ).toBe("https://127.0.0.1:18789/"); expect( resolveStatusDashboardUrl({ cfg: { diff --git a/src/commands/status-all/format.ts b/src/commands/status-all/format.ts index 62ae66c6f63..400492baa09 100644 --- a/src/commands/status-all/format.ts +++ b/src/commands/status-all/format.ts @@ -169,6 +169,7 @@ export function resolveStatusDashboardUrl(params: { bind: params.cfg.gateway?.bind, customBindHost: params.cfg.gateway?.customBindHost, basePath: params.cfg.gateway?.controlUi?.basePath, + tlsEnabled: params.cfg.gateway?.tls?.enabled === true, }).httpUrl; } diff --git a/src/gateway/control-ui-links.ts b/src/gateway/control-ui-links.ts index 720ecb97e1a..d39d2d9d502 100644 --- a/src/gateway/control-ui-links.ts +++ b/src/gateway/control-ui-links.ts @@ -10,6 +10,7 @@ export function resolveControlUiLinks(params: { bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet"; customBindHost?: string; basePath?: string; + tlsEnabled?: boolean; }): { httpUrl: string; wsUrl: string } { const port = params.port; const bind = params.bind ?? "loopback"; @@ -30,8 +31,10 @@ export function resolveControlUiLinks(params: { const basePath = normalizeControlUiBasePath(params.basePath); const uiPath = basePath ? `${basePath}/` : "/"; const wsPath = basePath ? basePath : ""; + const httpScheme = params.tlsEnabled === true ? "https" : "http"; + const wsScheme = params.tlsEnabled === true ? "wss" : "ws"; return { - httpUrl: `http://${host}:${port}${uiPath}`, - wsUrl: `ws://${host}:${port}${wsPath}`, + httpUrl: `${httpScheme}://${host}:${port}${uiPath}`, + wsUrl: `${wsScheme}://${host}:${port}${wsPath}`, }; } diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index c3c842552db..bf0a87ad329 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -242,6 +242,7 @@ export async function finalizeSetupWizard( port: settings.port, customBindHost: nextConfig.gateway?.customBindHost, basePath: undefined, + tlsEnabled: nextConfig.gateway?.tls?.enabled === true, }); // Daemon install/restart can briefly flap the WS; wait a bit so health check doesn't false-fail. gatewayProbe = await waitForGatewayReachable({ @@ -319,6 +320,7 @@ export async function finalizeSetupWizard( port: settings.port, customBindHost: settings.customBindHost, basePath: controlUiBasePath, + tlsEnabled: nextConfig.gateway?.tls?.enabled === true, }); const authedUrl = settings.authMode === "token" && settings.gatewayToken