diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index 070bb2575e3..3b676b66d70 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -83,6 +83,7 @@ describe("promptRemoteGatewayConfig", () => { displayName: "Gateway", host: "gateway.tailnet.ts.net", port: 18789, + gatewayTlsFingerprintSha256: "sha256:abc123", }, ]); @@ -111,12 +112,115 @@ describe("promptRemoteGatewayConfig", () => { 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(next.gateway?.remote?.tlsFingerprint).toBe("sha256:abc123"); expect(prompter.note).toHaveBeenCalledWith( expect.stringContaining("Direct remote access defaults to TLS."), "Direct remote", ); }); + it("rejects discovery endpoint when trust confirmation is declined", async () => { + detectBinary.mockResolvedValue(true); + discoverGatewayBeacons.mockResolvedValue([ + { + instanceName: "evil", + displayName: "Evil", + host: "evil.example", + port: 443, + gatewayTlsFingerprintSha256: "sha256:attacker", + }, + ]); + + const select = createSelectPrompter({ + "Select gateway": "0", + "Connection method": "direct", + }); + const confirm: WizardPrompter["confirm"] = vi.fn(async (params) => { + if (params.message.startsWith("Discover gateway")) { + return true; + } + if (params.message.startsWith("Trust this gateway")) { + return false; + } + return false; + }); + + const prompter = createPrompter({ + confirm, + select, + text: vi.fn(async () => "") as WizardPrompter["text"], + }); + + await expect(promptRemoteGatewayConfig({} as OpenClawConfig, prompter)).rejects.toThrow( + "not trusted", + ); + }); + + it("trusts discovery endpoint without fingerprint and omits tlsFingerprint", async () => { + detectBinary.mockResolvedValue(true); + discoverGatewayBeacons.mockResolvedValue([ + { + instanceName: "gw", + displayName: "Gateway", + host: "gw.example", + port: 18789, + }, + ]); + + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + return String(params.initialValue); + } + return ""; + }) as WizardPrompter["text"]; + + const { next } = await runRemotePrompt({ + text, + confirm: true, + selectResponses: { + "Select gateway": "0", + "Connection method": "direct", + "Gateway auth": "off", + }, + }); + + expect(next.gateway?.remote?.url).toBe("wss://gw.example:18789"); + expect(next.gateway?.remote?.tlsFingerprint).toBeUndefined(); + }); + + it("drops discovery tlsFingerprint when the URL is edited after trust confirmation", async () => { + detectBinary.mockResolvedValue(true); + discoverGatewayBeacons.mockResolvedValue([ + { + instanceName: "gateway", + displayName: "Gateway", + host: "gateway.tailnet.ts.net", + port: 18789, + gatewayTlsFingerprintSha256: "sha256:abc123", + }, + ]); + + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + return "wss://other.example:443"; + } + return ""; + }) as WizardPrompter["text"]; + + const { next } = await runRemotePrompt({ + text, + confirm: true, + selectResponses: { + "Select gateway": "0", + "Connection method": "direct", + "Gateway auth": "off", + }, + }); + + expect(next.gateway?.remote?.url).toBe("wss://other.example:443"); + expect(next.gateway?.remote?.tlsFingerprint).toBeUndefined(); + }); + it("does not route from TXT-only discovery metadata", async () => { detectBinary.mockResolvedValue(true); discoverGatewayBeacons.mockResolvedValue([ diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index 362a1b417c0..263771b3ae3 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -52,6 +52,8 @@ export async function promptRemoteGatewayConfig( ): Promise { let selectedBeacon: GatewayBonjourBeacon | null = null; let suggestedUrl = cfg.gateway?.remote?.url ?? DEFAULT_GATEWAY_URL; + let discoveryTlsFingerprint: string | undefined; + let trustedDiscoveryUrl: string | undefined; const hasBonjourTool = (await detectBinary("dns-sd")) || (await detectBinary("avahi-browse")); const wantsDiscover = hasBonjourTool @@ -113,10 +115,23 @@ export async function promptRemoteGatewayConfig( }); if (mode === "direct") { suggestedUrl = `wss://${host}:${port}`; + const fingerprint = target.endpoint.gatewayTlsFingerprintSha256; + const trusted = await prompter.confirm({ + message: `Trust this gateway? Host: ${host}:${port} TLS fingerprint: ${fingerprint ?? "not advertised (connection will not be pinned)"}`, + initialValue: false, + }); + if (!trusted) { + throw new Error( + `Discovery endpoint ${host}:${port} not trusted. Re-run onboarding or enter the URL manually.`, + ); + } + discoveryTlsFingerprint = fingerprint; + trustedDiscoveryUrl = suggestedUrl; await prompter.note( [ "Direct remote access defaults to TLS.", `Using: ${suggestedUrl}`, + ...(fingerprint ? [`TLS pin: ${fingerprint}`] : []), "If your gateway is loopback-only, choose SSH tunnel and keep ws://127.0.0.1:18789.", ].join("\n"), "Direct remote", @@ -141,6 +156,8 @@ export async function promptRemoteGatewayConfig( validate: (value) => validateGatewayWebSocketUrl(String(value)), }); const url = ensureWsUrl(String(urlInput)); + const pinnedDiscoveryFingerprint = + discoveryTlsFingerprint && url === trustedDiscoveryUrl ? discoveryTlsFingerprint : undefined; const authChoice = await prompter.select({ message: "Gateway auth", @@ -231,6 +248,7 @@ export async function promptRemoteGatewayConfig( url, ...(token !== undefined ? { token } : {}), ...(password !== undefined ? { password } : {}), + ...(pinnedDiscoveryFingerprint ? { tlsFingerprint: pinnedDiscoveryFingerprint } : {}), }, }, };