mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
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 <wang_hgang@msn.com>
This commit is contained in:
parent
8cbb62d93c
commit
df6c58cf30
18 changed files with 110 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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://<host>:18789/`
|
||||
- with `gateway.tls.enabled: true`: `https://<host>: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`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue