mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 08:21:39 +00:00
fix(gateway): fail closed on unresolved discovery endpoints
This commit is contained in:
@@ -84,6 +84,12 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/discovery: fail closed on unresolved Bonjour and DNS-SD service endpoints in CLI discovery, onboarding, and `gateway status` so TXT-only hints can no longer steer routing or SSH auto-target selection. Thanks @nexrin for reporting.
|
||||
- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey.
|
||||
- Web tools/Exa: align the bundled Exa plugin with the current Exa API by supporting newer search types and richer `contents` options, while fixing the result-count cap to honor Exa's higher limit. Thanks @vincentkoc.
|
||||
- Plugins/Matrix: move bundled plugin `KeyedAsyncQueue` imports onto the stable `plugin-sdk/core` surface so Matrix Docker/runtime builds do not depend on the brittle keyed-async-queue subpath. Thanks @ecohash-co and @vincentkoc.
|
||||
- Nostr/security: enforce inbound DM policy before decrypt, route Nostr DMs through the standard reply pipeline, and add pre-crypto rate and size guards so unknown senders cannot bypass pairing or force unbounded crypto work. Thanks @kuranikaran.
|
||||
- Synology Chat/security: keep reply delivery bound to stable numeric `user_id` by default, and gate mutable username/nickname recipient lookup behind `dangerouslyAllowNameMatching` with new regression coverage. Thanks @nexrin.
|
||||
- Agents/default timeout: raise the shared default agent timeout from `600s` to `48h` so long-running ACP and agent sessions do not fail unless you configure a shorter limit.
|
||||
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
|
||||
- Gateway/startup: prewarm the configured primary model before channel startup and retry one transient provider-runtime miss so the first Telegram or Discord message after boot no longer fails with `Unknown model: openai-codex/gpt-5.4`. Thanks @vincentkoc.
|
||||
|
||||
@@ -81,7 +81,8 @@ vi.mock("../daemon/program-args.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/bonjour-discovery.js", () => ({
|
||||
vi.mock("../infra/bonjour-discovery.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../infra/bonjour-discovery.js")>()),
|
||||
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
|
||||
}));
|
||||
|
||||
@@ -147,6 +148,7 @@ describe("gateway-cli coverage", () => {
|
||||
displayName: "Studio",
|
||||
domain: "openclaw.internal.",
|
||||
host: "studio.openclaw.internal",
|
||||
port: 18789,
|
||||
lanHost: "studio.local",
|
||||
tailnetDns: "studio.tailnet.ts.net",
|
||||
gatewayPort: 18789,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { GatewayBonjourBeacon } from "../../infra/bonjour-discovery.js";
|
||||
import {
|
||||
type GatewayBonjourBeacon,
|
||||
pickResolvedGatewayHost,
|
||||
pickResolvedGatewayPort,
|
||||
} from "../../infra/bonjour-discovery.js";
|
||||
import { colorize, theme } from "../../terminal/theme.js";
|
||||
import { parseTimeoutMsWithFallback } from "../parse-timeout.js";
|
||||
|
||||
@@ -13,15 +17,14 @@ export function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number
|
||||
|
||||
export function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null {
|
||||
// Security: TXT records are unauthenticated. Prefer the resolved service endpoint (SRV/A/AAAA)
|
||||
// over TXT-provided routing hints.
|
||||
const host = beacon.host || beacon.tailnetDns || beacon.lanHost;
|
||||
return host?.trim() ? host.trim() : null;
|
||||
// and fail closed when discovery did not resolve a routable host.
|
||||
return pickResolvedGatewayHost(beacon);
|
||||
}
|
||||
|
||||
export function pickGatewayPort(beacon: GatewayBonjourBeacon): number {
|
||||
export function pickGatewayPort(beacon: GatewayBonjourBeacon): number | null {
|
||||
// Security: TXT records are unauthenticated. Prefer the resolved service port over TXT gatewayPort.
|
||||
const port = beacon.port ?? beacon.gatewayPort ?? 18789;
|
||||
return port > 0 ? port : 18789;
|
||||
// Fail closed when discovery did not resolve a routable port.
|
||||
return pickResolvedGatewayPort(beacon);
|
||||
}
|
||||
|
||||
export function dedupeBeacons(beacons: GatewayBonjourBeacon[]): GatewayBonjourBeacon[] {
|
||||
@@ -56,7 +59,7 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
|
||||
const host = pickBeaconHost(beacon);
|
||||
const gatewayPort = pickGatewayPort(beacon);
|
||||
const scheme = beacon.gatewayTls ? "wss" : "ws";
|
||||
const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null;
|
||||
const wsUrl = host && gatewayPort ? `${scheme}://${host}:${gatewayPort}` : null;
|
||||
|
||||
const lines = [`- ${title} ${domain}`];
|
||||
|
||||
|
||||
@@ -444,13 +444,13 @@ describe("gateway discover routing helpers", () => {
|
||||
expect(pickGatewayPort(beacon)).toBe(18789);
|
||||
});
|
||||
|
||||
it("falls back to TXT host/port when resolve data is missing", () => {
|
||||
it("fails closed when resolve data is missing", () => {
|
||||
const beacon: GatewayBonjourBeacon = {
|
||||
instanceName: "Test",
|
||||
lanHost: "test-host.local",
|
||||
gatewayPort: 18789,
|
||||
};
|
||||
expect(pickBeaconHost(beacon)).toBe("test-host.local");
|
||||
expect(pickGatewayPort(beacon)).toBe(18789);
|
||||
expect(pickBeaconHost(beacon)).toBeNull();
|
||||
expect(pickGatewayPort(beacon)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayProbeResult } from "../gateway/probe.js";
|
||||
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
|
||||
@@ -12,7 +13,7 @@ const readBestEffortConfig = vi.fn(async () => ({
|
||||
}));
|
||||
const resolveGatewayPort = vi.fn((_cfg?: unknown) => 18789);
|
||||
const discoverGatewayBeacons = vi.fn(
|
||||
async (_opts?: unknown): Promise<Array<{ tailnetDns: string }>> => [],
|
||||
async (_opts?: unknown): Promise<GatewayBonjourBeacon[]> => [],
|
||||
);
|
||||
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
|
||||
const sshStop = vi.fn(async () => {});
|
||||
@@ -117,9 +118,13 @@ vi.mock("../config/config.js", () => ({
|
||||
resolveGatewayPort,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/bonjour-discovery.js", () => ({
|
||||
discoverGatewayBeacons,
|
||||
}));
|
||||
vi.mock("../infra/bonjour-discovery.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/bonjour-discovery.js")>();
|
||||
return {
|
||||
...actual,
|
||||
discoverGatewayBeacons,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4,
|
||||
@@ -220,6 +225,27 @@ describe("gateway-status command", () => {
|
||||
expect(targets[0]?.summary).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits discovery wsUrl when only TXT hints are present", async () => {
|
||||
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||
discoverGatewayBeacons.mockResolvedValueOnce([
|
||||
{
|
||||
instanceName: "gateway",
|
||||
displayName: "Gateway",
|
||||
tailnetDns: "attacker.tailnet.ts.net",
|
||||
lanHost: "attacker.example.com",
|
||||
gatewayPort: 19443,
|
||||
},
|
||||
]);
|
||||
|
||||
await runGatewayStatus(runtime, { timeout: "1000", json: true });
|
||||
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
|
||||
discovery?: { beacons?: Array<{ wsUrl?: string | null }> };
|
||||
};
|
||||
expect(parsed.discovery?.beacons?.[0]?.wsUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps status output working when tailnet discovery throws", async () => {
|
||||
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||
pickPrimaryTailnetIPv4.mockImplementationOnce(() => {
|
||||
@@ -625,13 +651,29 @@ describe("gateway-status command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("skips invalid ssh-auto discovery targets", async () => {
|
||||
it("does not infer ssh-auto targets from TXT-only discovery metadata", async () => {
|
||||
const { runtime } = createRuntimeCapture();
|
||||
await withEnvAsync({ USER: "steipete" }, async () => {
|
||||
readBestEffortConfig.mockResolvedValueOnce(makeRemoteGatewayConfig("", "", "ltok"));
|
||||
discoverGatewayBeacons.mockResolvedValueOnce([
|
||||
{ tailnetDns: "-V" },
|
||||
{ tailnetDns: "goodhost" },
|
||||
{ instanceName: "bad", tailnetDns: "-V" },
|
||||
{ instanceName: "txt-only", tailnetDns: "goodhost" },
|
||||
]);
|
||||
|
||||
startSshPortForward.mockClear();
|
||||
await runGatewayStatus(runtime, { timeout: "1000", json: true, sshAuto: true });
|
||||
|
||||
expect(startSshPortForward).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("infers ssh-auto targets from resolved discovery hosts", async () => {
|
||||
const { runtime } = createRuntimeCapture();
|
||||
await withEnvAsync({ USER: "steipete" }, async () => {
|
||||
readBestEffortConfig.mockResolvedValueOnce(makeRemoteGatewayConfig("", "", "ltok"));
|
||||
discoverGatewayBeacons.mockResolvedValueOnce([
|
||||
{ instanceName: "bad", tailnetDns: "-V" },
|
||||
{ host: "goodhost", sshPort: 2222, port: 18789, instanceName: "Gateway" },
|
||||
]);
|
||||
|
||||
startSshPortForward.mockClear();
|
||||
@@ -639,7 +681,7 @@ describe("gateway-status command", () => {
|
||||
|
||||
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
||||
const call = startSshPortForward.mock.calls[0]?.[0] as { target: string };
|
||||
expect(call.target).toBe("steipete@goodhost");
|
||||
expect(call.target).toBe("steipete@goodhost:2222");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js";
|
||||
import { probeGateway } from "../gateway/probe.js";
|
||||
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||
import {
|
||||
discoverGatewayBeacons,
|
||||
pickResolvedGatewayHost,
|
||||
pickResolvedGatewayPort,
|
||||
} from "../infra/bonjour-discovery.js";
|
||||
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
@@ -123,12 +127,12 @@ export async function gatewayStatusCommand(
|
||||
const user = process.env.USER?.trim() || "";
|
||||
const candidates = discovery
|
||||
.map((b) => {
|
||||
const host = b.tailnetDns || b.lanHost || b.host;
|
||||
if (!host?.trim()) {
|
||||
const host = pickResolvedGatewayHost(b);
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
const sshPort = typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22;
|
||||
const base = user ? `${user}@${host.trim()}` : host.trim();
|
||||
const base = user ? `${user}@${host}` : host;
|
||||
return sshPort !== 22 ? `${base}:${sshPort}` : base;
|
||||
})
|
||||
.filter((candidate): candidate is string => Boolean(candidate));
|
||||
@@ -286,9 +290,9 @@ export async function gatewayStatusCommand(
|
||||
gatewayPort: b.gatewayPort ?? null,
|
||||
sshPort: b.sshPort ?? null,
|
||||
wsUrl: (() => {
|
||||
const host = b.tailnetDns || b.lanHost || b.host;
|
||||
const port = b.gatewayPort ?? 18789;
|
||||
return host ? `ws://${host}:${port}` : null;
|
||||
const host = pickResolvedGatewayHost(b);
|
||||
const port = pickResolvedGatewayPort(b);
|
||||
return host && port ? `ws://${host}:${port}` : null;
|
||||
})(),
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -9,9 +9,13 @@ const discoverGatewayBeacons = vi.hoisted(() => vi.fn<() => Promise<GatewayBonjo
|
||||
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/bonjour-discovery.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/bonjour-discovery.js")>();
|
||||
return {
|
||||
...actual,
|
||||
discoverGatewayBeacons,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/widearea-dns.js", () => ({
|
||||
resolveWideAreaDiscoveryDomain,
|
||||
@@ -113,6 +117,49 @@ describe("promptRemoteGatewayConfig", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not route from TXT-only discovery metadata", async () => {
|
||||
detectBinary.mockResolvedValue(true);
|
||||
discoverGatewayBeacons.mockResolvedValue([
|
||||
{
|
||||
instanceName: "gateway",
|
||||
displayName: "Gateway",
|
||||
lanHost: "attacker.example.com",
|
||||
tailnetDns: "attacker.tailnet.ts.net",
|
||||
gatewayPort: 19443,
|
||||
sshPort: 2222,
|
||||
},
|
||||
]);
|
||||
|
||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
||||
if (params.message === "Select gateway") {
|
||||
return "0" as never;
|
||||
}
|
||||
if (params.message === "Gateway auth") {
|
||||
return "off" 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("ws://127.0.0.1:18789");
|
||||
return String(params.initialValue);
|
||||
}
|
||||
return "";
|
||||
}) as WizardPrompter["text"];
|
||||
const prompter = createPrompter({
|
||||
confirm: vi.fn(async () => true),
|
||||
select,
|
||||
text,
|
||||
});
|
||||
|
||||
const next = await promptRemoteGatewayConfig({} as OpenClawConfig, prompter);
|
||||
|
||||
expect(next.gateway?.remote?.url).toBe("ws://127.0.0.1:18789");
|
||||
expect(select).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Connection method" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates insecure ws:// remote URLs and allows only loopback ws:// by default", async () => {
|
||||
const text: WizardPrompter["text"] = vi.fn(async (params) => {
|
||||
if (params.message === "Gateway WebSocket URL") {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SecretInput } from "../config/types.secrets.js";
|
||||
import { isSecureWebSocketUrl } from "../gateway/net.js";
|
||||
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
|
||||
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||
import {
|
||||
discoverGatewayBeacons,
|
||||
pickResolvedGatewayHost,
|
||||
pickResolvedGatewayPort,
|
||||
type GatewayBonjourBeacon,
|
||||
} from "../infra/bonjour-discovery.js";
|
||||
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
|
||||
import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js";
|
||||
import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js";
|
||||
@@ -14,15 +18,19 @@ const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
|
||||
function pickHost(beacon: GatewayBonjourBeacon): string | undefined {
|
||||
// Security: TXT is unauthenticated. Prefer the resolved service endpoint host.
|
||||
return beacon.host || beacon.tailnetDns || beacon.lanHost;
|
||||
return pickResolvedGatewayHost(beacon) ?? undefined;
|
||||
}
|
||||
|
||||
function pickPort(beacon: GatewayBonjourBeacon): number | undefined {
|
||||
// Security: TXT is unauthenticated. Prefer the resolved service endpoint port.
|
||||
return pickResolvedGatewayPort(beacon) ?? undefined;
|
||||
}
|
||||
|
||||
function buildLabel(beacon: GatewayBonjourBeacon): string {
|
||||
const host = pickHost(beacon);
|
||||
// Security: Prefer the resolved service endpoint port.
|
||||
const port = beacon.port ?? beacon.gatewayPort ?? 18789;
|
||||
const port = pickPort(beacon);
|
||||
const title = beacon.displayName ?? beacon.instanceName;
|
||||
const hint = host ? `${host}:${port}` : "host unknown";
|
||||
const hint = host && port ? `${host}:${port}` : "host unknown";
|
||||
return `${title} (${hint})`;
|
||||
}
|
||||
|
||||
@@ -106,8 +114,8 @@ export async function promptRemoteGatewayConfig(
|
||||
|
||||
if (selectedBeacon) {
|
||||
const host = pickHost(selectedBeacon);
|
||||
const port = selectedBeacon.port ?? selectedBeacon.gatewayPort ?? 18789;
|
||||
if (host) {
|
||||
const port = pickPort(selectedBeacon);
|
||||
if (host && port) {
|
||||
const mode = await prompter.select({
|
||||
message: "Connection method",
|
||||
options: [
|
||||
|
||||
@@ -20,6 +20,16 @@ export type GatewayBonjourBeacon = {
|
||||
txt?: Record<string, string>;
|
||||
};
|
||||
|
||||
export function pickResolvedGatewayHost(beacon: GatewayBonjourBeacon): string | null {
|
||||
const host = beacon.host?.trim();
|
||||
return host ? host : null;
|
||||
}
|
||||
|
||||
export function pickResolvedGatewayPort(beacon: GatewayBonjourBeacon): number | null {
|
||||
const port = beacon.port;
|
||||
return typeof port === "number" && Number.isFinite(port) && port > 0 ? port : null;
|
||||
}
|
||||
|
||||
export type GatewayBonjourDiscoverOpts = {
|
||||
timeoutMs?: number;
|
||||
domains?: string[];
|
||||
|
||||
Reference in New Issue
Block a user