mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 19:01:44 +00:00
CLI: confirm discovered remote gateways before saving config (#55895)
* CLI: require trust confirmation for discovered remote gateways Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com> * CLI: clear discovery pin when remote URL changes --------- Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -52,6 +52,8 @@ export async function promptRemoteGatewayConfig(
|
||||
): Promise<OpenClawConfig> {
|
||||
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 } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user