mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-27 00:17:29 +00:00
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();
|
const message = lastMessage();
|
||||||
expect(message).toContain("CRITICAL");
|
expect(message).toContain("CRITICAL");
|
||||||
expect(message).toContain("without authentication");
|
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 () => {
|
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 === "token" && hasToken) ||
|
||||||
(resolvedAuth.mode === "password" && hasPassword);
|
(resolvedAuth.mode === "password" && hasPassword);
|
||||||
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
|
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 (isExposed) {
|
||||||
if (!hasSharedSecret) {
|
if (!hasSharedSecret) {
|
||||||
@@ -61,6 +66,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
|||||||
`- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
|
`- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
|
||||||
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
|
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
|
||||||
` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`,
|
` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`,
|
||||||
|
...saferRemoteAccessLines,
|
||||||
...authFixLines,
|
...authFixLines,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -68,6 +74,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
|
|||||||
warnings.push(
|
warnings.push(
|
||||||
`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
|
`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
|
||||||
` Ensure your auth credentials are strong and not exposed.`,
|
` 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 type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { isSecureWebSocketUrl } from "../gateway/net.js";
|
||||||
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
|
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
|
||||||
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||||
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
|
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
|
||||||
@@ -29,6 +30,17 @@ function ensureWsUrl(value: string): string {
|
|||||||
return trimmed;
|
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(
|
export async function promptRemoteGatewayConfig(
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
prompter: WizardPrompter,
|
prompter: WizardPrompter,
|
||||||
@@ -95,7 +107,15 @@ export async function promptRemoteGatewayConfig(
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (mode === "direct") {
|
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 {
|
} else {
|
||||||
suggestedUrl = DEFAULT_GATEWAY_URL;
|
suggestedUrl = DEFAULT_GATEWAY_URL;
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
@@ -115,10 +135,7 @@ export async function promptRemoteGatewayConfig(
|
|||||||
const urlInput = await prompter.text({
|
const urlInput = await prompter.text({
|
||||||
message: "Gateway WebSocket URL",
|
message: "Gateway WebSocket URL",
|
||||||
initialValue: suggestedUrl,
|
initialValue: suggestedUrl,
|
||||||
validate: (value) =>
|
validate: (value) => validateGatewayWebSocketUrl(String(value)),
|
||||||
String(value).trim().startsWith("ws://") || String(value).trim().startsWith("wss://")
|
|
||||||
? undefined
|
|
||||||
: "URL must start with ws:// or wss://",
|
|
||||||
});
|
});
|
||||||
const url = ensureWsUrl(String(urlInput));
|
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("SECURITY ERROR");
|
||||||
expect((thrown as Error).message).toContain("plaintext ws://");
|
expect((thrown as Error).message).toContain("plaintext ws://");
|
||||||
expect((thrown as Error).message).toContain("wss://");
|
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", () => {
|
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.",
|
"Both credentials and chat data would be exposed to network interception.",
|
||||||
`Source: ${urlSource}`,
|
`Source: ${urlSource}`,
|
||||||
`Config: ${configPath}`,
|
`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"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ describe("GatewayClient security checks", () => {
|
|||||||
message: expect.stringContaining("SECURITY ERROR"),
|
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
|
expect(wsInstances.length).toBe(0); // No WebSocket created
|
||||||
client.stop();
|
client.stop();
|
||||||
});
|
});
|
||||||
@@ -149,6 +152,8 @@ describe("GatewayClient security checks", () => {
|
|||||||
message: expect.stringContaining("SECURITY ERROR"),
|
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
|
expect(wsInstances.length).toBe(0); // No WebSocket created
|
||||||
client.stop();
|
client.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ export class GatewayClient {
|
|||||||
const error = new Error(
|
const error = new Error(
|
||||||
`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
|
`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
|
||||||
"Both credentials and chat data would be exposed to network interception. " +
|
"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);
|
this.opts.onConnectError?.(error);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user