fix(gateway): fail closed on unresolved discovery endpoints

This commit is contained in:
Peter Steinberger
2026-03-23 00:25:03 -07:00
parent 0b58829364
commit deecf68b59
9 changed files with 160 additions and 38 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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}`];

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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;
})(),
})),
},

View File

@@ -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") {

View File

@@ -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: [

View File

@@ -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[];