Gateway UX: harden remote ws guidance and onboarding defaults
This commit is contained in:
committed by
Peter Steinberger
parent
6fda04e938
commit
8a3d04c19c
@@ -48,6 +48,8 @@ describe("noteSecurityWarnings gateway exposure", () => {
|
||||
const message = lastMessage();
|
||||
expect(message).toContain("CRITICAL");
|
||||
expect(message).toContain("without authentication");
|
||||
expect(message).toContain("Safer remote access");
|
||||
expect(message).toContain("ssh -N -L 18789:127.0.0.1:18789");
|
||||
});
|
||||
|
||||
it("uses env token to avoid critical warning", async () => {
|
||||
|
||||
@@ -42,6 +42,11 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
||||
(resolvedAuth.mode === "token" && hasToken) ||
|
||||
(resolvedAuth.mode === "password" && hasPassword);
|
||||
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
|
||||
const saferRemoteAccessLines = [
|
||||
" Safer remote access: keep bind loopback and use Tailscale Serve/Funnel or an SSH tunnel.",
|
||||
" Example tunnel: ssh -N -L 18789:127.0.0.1:18789 user@gateway-host",
|
||||
" Docs: https://docs.openclaw.ai/gateway/remote",
|
||||
];
|
||||
|
||||
if (isExposed) {
|
||||
if (!hasSharedSecret) {
|
||||
@@ -61,6 +66,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
||||
`- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
|
||||
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
|
||||
` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`,
|
||||
...saferRemoteAccessLines,
|
||||
...authFixLines,
|
||||
);
|
||||
} else {
|
||||
@@ -68,6 +74,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
||||
warnings.push(
|
||||
`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
|
||||
` Ensure your auth credentials are strong and not exposed.`,
|
||||
...saferRemoteAccessLines,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
122
src/commands/onboard-remote.test.ts
Normal file
122
src/commands/onboard-remote.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { createWizardPrompter } from "./test-wizard-helpers.js";
|
||||
|
||||
const discoverGatewayBeacons = vi.hoisted(() => vi.fn<() => Promise<GatewayBonjourBeacon[]>>());
|
||||
const resolveWideAreaDiscoveryDomain = vi.hoisted(() => vi.fn(() => undefined));
|
||||
const detectBinary = vi.hoisted(() => vi.fn<(name: string) => Promise<boolean>>());
|
||||
|
||||
vi.mock("../infra/bonjour-discovery.js", () => ({
|
||||
discoverGatewayBeacons,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/widearea-dns.js", () => ({
|
||||
resolveWideAreaDiscoveryDomain,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
detectBinary,
|
||||
}));
|
||||
|
||||
const { promptRemoteGatewayConfig } = await import("./onboard-remote.js");
|
||||
|
||||
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
||||
return createWizardPrompter(overrides, { defaultSelect: "" });
|
||||
}
|
||||
|
||||
describe("promptRemoteGatewayConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
detectBinary.mockResolvedValue(false);
|
||||
discoverGatewayBeacons.mockResolvedValue([]);
|
||||
resolveWideAreaDiscoveryDomain.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("defaults discovered direct remote URLs to wss://", async () => {
|
||||
detectBinary.mockResolvedValue(true);
|
||||
discoverGatewayBeacons.mockResolvedValue([
|
||||
{
|
||||
instanceName: "gateway",
|
||||
displayName: "Gateway",
|
||||
host: "gateway.tailnet.ts.net",
|
||||
port: 18789,
|
||||
},
|
||||
]);
|
||||
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Select gateway") {
|
||||
return "0" as never;
|
||||
}
|
||||
if (params.message === "Connection method") {
|
||||
return "direct" as never;
|
||||
}
|
||||
if (params.message === "Gateway auth") {
|
||||
return "token" as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
|
||||
const text: WizardPrompter["text"] = vi.fn(async (params) => {
|
||||
if (params.message === "Gateway WebSocket URL") {
|
||||
expect(params.initialValue).toBe("wss://gateway.tailnet.ts.net:18789");
|
||||
expect(params.validate?.(String(params.initialValue))).toBeUndefined();
|
||||
return String(params.initialValue);
|
||||
}
|
||||
if (params.message === "Gateway token") {
|
||||
return "token-123";
|
||||
}
|
||||
return "";
|
||||
}) as WizardPrompter["text"];
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const prompter = createPrompter({
|
||||
confirm: vi.fn(async () => true),
|
||||
select,
|
||||
text,
|
||||
});
|
||||
|
||||
const next = await promptRemoteGatewayConfig(cfg, prompter);
|
||||
|
||||
expect(next.gateway?.mode).toBe("remote");
|
||||
expect(next.gateway?.remote?.url).toBe("wss://gateway.tailnet.ts.net:18789");
|
||||
expect(next.gateway?.remote?.token).toBe("token-123");
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Direct remote access defaults to TLS."),
|
||||
"Direct remote",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates insecure ws:// remote URLs and allows loopback ws://", async () => {
|
||||
const text: WizardPrompter["text"] = vi.fn(async (params) => {
|
||||
if (params.message === "Gateway WebSocket URL") {
|
||||
expect(params.validate?.("ws://10.0.0.8:18789")).toContain("Use wss://");
|
||||
expect(params.validate?.("ws://127.0.0.1:18789")).toBeUndefined();
|
||||
expect(params.validate?.("wss://remote.example.com:18789")).toBeUndefined();
|
||||
return "wss://remote.example.com:18789";
|
||||
}
|
||||
return "";
|
||||
}) as WizardPrompter["text"];
|
||||
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Gateway auth") {
|
||||
return "off" as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "") as never;
|
||||
});
|
||||
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const prompter = createPrompter({
|
||||
confirm: vi.fn(async () => false),
|
||||
select,
|
||||
text,
|
||||
});
|
||||
|
||||
const next = await promptRemoteGatewayConfig(cfg, prompter);
|
||||
|
||||
expect(next.gateway?.mode).toBe("remote");
|
||||
expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789");
|
||||
expect(next.gateway?.remote?.token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isSecureWebSocketUrl } from "../gateway/net.js";
|
||||
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
|
||||
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
|
||||
@@ -29,6 +30,17 @@ function ensureWsUrl(value: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function validateGatewayWebSocketUrl(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) {
|
||||
return "URL must start with ws:// or wss://";
|
||||
}
|
||||
if (!isSecureWebSocketUrl(trimmed)) {
|
||||
return "Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function promptRemoteGatewayConfig(
|
||||
cfg: OpenClawConfig,
|
||||
prompter: WizardPrompter,
|
||||
@@ -95,7 +107,15 @@ export async function promptRemoteGatewayConfig(
|
||||
],
|
||||
});
|
||||
if (mode === "direct") {
|
||||
suggestedUrl = `ws://${host}:${port}`;
|
||||
suggestedUrl = `wss://${host}:${port}`;
|
||||
await prompter.note(
|
||||
[
|
||||
"Direct remote access defaults to TLS.",
|
||||
`Using: ${suggestedUrl}`,
|
||||
"If your gateway is loopback-only, choose SSH tunnel and keep ws://127.0.0.1:18789.",
|
||||
].join("\n"),
|
||||
"Direct remote",
|
||||
);
|
||||
} else {
|
||||
suggestedUrl = DEFAULT_GATEWAY_URL;
|
||||
await prompter.note(
|
||||
@@ -115,10 +135,7 @@ export async function promptRemoteGatewayConfig(
|
||||
const urlInput = await prompter.text({
|
||||
message: "Gateway WebSocket URL",
|
||||
initialValue: suggestedUrl,
|
||||
validate: (value) =>
|
||||
String(value).trim().startsWith("ws://") || String(value).trim().startsWith("wss://")
|
||||
? undefined
|
||||
: "URL must start with ws:// or wss://",
|
||||
validate: (value) => validateGatewayWebSocketUrl(String(value)),
|
||||
});
|
||||
const url = ensureWsUrl(String(urlInput));
|
||||
|
||||
|
||||
@@ -334,6 +334,8 @@ describe("buildGatewayConnectionDetails", () => {
|
||||
expect((thrown as Error).message).toContain("SECURITY ERROR");
|
||||
expect((thrown as Error).message).toContain("plaintext ws://");
|
||||
expect((thrown as Error).message).toContain("wss://");
|
||||
expect((thrown as Error).message).toContain("Tailscale Serve/Funnel");
|
||||
expect((thrown as Error).message).toContain("openclaw doctor --fix");
|
||||
});
|
||||
|
||||
it("allows ws:// for loopback addresses in local mode", () => {
|
||||
|
||||
@@ -149,7 +149,12 @@ export function buildGatewayConnectionDetails(
|
||||
"Both credentials and chat data would be exposed to network interception.",
|
||||
`Source: ${urlSource}`,
|
||||
`Config: ${configPath}`,
|
||||
"Fix: Use wss:// for the gateway URL, or connect via SSH tunnel to localhost.",
|
||||
"Fix: Use wss:// for remote gateway URLs.",
|
||||
"Safe remote access defaults:",
|
||||
"- keep gateway.bind=loopback and use an SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host)",
|
||||
"- or use Tailscale Serve/Funnel for HTTPS remote access",
|
||||
"Doctor: openclaw doctor --fix",
|
||||
"Docs: https://docs.openclaw.ai/gateway/remote",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,9 @@ describe("GatewayClient security checks", () => {
|
||||
message: expect.stringContaining("SECURITY ERROR"),
|
||||
}),
|
||||
);
|
||||
const error = onConnectError.mock.calls[0]?.[0] as Error;
|
||||
expect(error.message).toContain("openclaw doctor --fix");
|
||||
expect(error.message).toContain("Tailscale Serve/Funnel");
|
||||
expect(wsInstances.length).toBe(0); // No WebSocket created
|
||||
client.stop();
|
||||
});
|
||||
@@ -149,6 +152,8 @@ describe("GatewayClient security checks", () => {
|
||||
message: expect.stringContaining("SECURITY ERROR"),
|
||||
}),
|
||||
);
|
||||
const error = onConnectError.mock.calls[0]?.[0] as Error;
|
||||
expect(error.message).toContain("openclaw doctor --fix");
|
||||
expect(wsInstances.length).toBe(0); // No WebSocket created
|
||||
client.stop();
|
||||
});
|
||||
|
||||
@@ -126,7 +126,9 @@ export class GatewayClient {
|
||||
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 the gateway URL, or connect via SSH tunnel to localhost.",
|
||||
"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. " +
|
||||
"Run `openclaw doctor --fix` for guidance.",
|
||||
);
|
||||
this.opts.onConnectError?.(error);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user