refactor(gateway): centralize discovery target handling

This commit is contained in:
Peter Steinberger
2026-03-23 00:37:54 -07:00
parent 9fbb840c79
commit fe5819887b
11 changed files with 649 additions and 438 deletions

View File

@@ -1,8 +1,5 @@
import {
type GatewayBonjourBeacon,
pickResolvedGatewayHost,
pickResolvedGatewayPort,
} from "../../infra/bonjour-discovery.js";
import type { GatewayBonjourBeacon } from "../../infra/bonjour-discovery.js";
import { buildGatewayDiscoveryTarget } from "../../infra/gateway-discovery-targets.js";
import { colorize, theme } from "../../terminal/theme.js";
import { parseTimeoutMsWithFallback } from "../parse-timeout.js";
@@ -16,15 +13,11 @@ 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)
// and fail closed when discovery did not resolve a routable host.
return pickResolvedGatewayHost(beacon);
return buildGatewayDiscoveryTarget(beacon).endpoint?.host ?? null;
}
export function pickGatewayPort(beacon: GatewayBonjourBeacon): number | null {
// Security: TXT records are unauthenticated. Prefer the resolved service port over TXT gatewayPort.
// Fail closed when discovery did not resolve a routable port.
return pickResolvedGatewayPort(beacon);
return buildGatewayDiscoveryTarget(beacon).endpoint?.port ?? null;
}
export function dedupeBeacons(beacons: GatewayBonjourBeacon[]): GatewayBonjourBeacon[] {
@@ -50,16 +43,9 @@ export function dedupeBeacons(beacons: GatewayBonjourBeacon[]): GatewayBonjourBe
}
export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean): string[] {
const nameRaw = (beacon.displayName || beacon.instanceName || "Gateway").trim();
const domainRaw = (beacon.domain || "local.").trim();
const title = colorize(rich, theme.accentBright, nameRaw);
const domain = colorize(rich, theme.muted, domainRaw);
const host = pickBeaconHost(beacon);
const gatewayPort = pickGatewayPort(beacon);
const scheme = beacon.gatewayTls ? "wss" : "ws";
const wsUrl = host && gatewayPort ? `${scheme}://${host}:${gatewayPort}` : null;
const target = buildGatewayDiscoveryTarget(beacon);
const title = colorize(rich, theme.accentBright, target.title);
const domain = colorize(rich, theme.muted, target.domain);
const lines = [`- ${title} ${domain}`];
@@ -73,8 +59,10 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
lines.push(` ${colorize(rich, theme.info, "host")}: ${beacon.host}`);
}
if (wsUrl) {
lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`);
if (target.wsUrl) {
lines.push(
` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, target.wsUrl)}`,
);
}
if (beacon.role) {
lines.push(` ${colorize(rich, theme.muted, "role")}: ${beacon.role}`);
@@ -88,8 +76,8 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
: "enabled";
lines.push(` ${colorize(rich, theme.muted, "tls")}: ${fingerprint}`);
}
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
if (target.endpoint && target.sshPort) {
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${target.endpoint.host} -p ${target.sshPort}`;
lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`);
}
return lines;

View File

@@ -428,6 +428,7 @@ describe("gateway discover routing helpers", () => {
const beacon: GatewayBonjourBeacon = {
instanceName: "Test",
host: "10.0.0.2",
port: 18789,
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
};

View File

@@ -1,29 +1,22 @@
import { withProgress } from "../cli/progress.js";
import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js";
import { probeGateway } from "../gateway/probe.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";
import type { RuntimeEnv } from "../runtime.js";
import { isRich } from "../terminal/theme.js";
import { inferSshTargetFromRemoteUrl, resolveSshTarget } from "./gateway-status/discovery.js";
import {
buildNetworkHints,
extractConfigSummary,
isProbeReachable,
isScopeLimitedProbeFailure,
type GatewayStatusTarget,
parseTimeoutMs,
pickGatewaySelfPresence,
renderProbeSummaryLine,
renderTargetHeader,
resolveAuthForTarget,
resolveProbeBudgetMs,
resolveTargets,
sanitizeSshTarget,
} from "./gateway-status/helpers.js";
import {
buildGatewayStatusWarnings,
pickPrimaryProbedTarget,
writeGatewayStatusJson,
writeGatewayStatusText,
} from "./gateway-status/output.js";
import { runGatewayStatusProbePass } from "./gateway-status/probe-run.js";
let sshConfigModulePromise: Promise<typeof import("../infra/ssh-config.js")> | undefined;
let sshTunnelModulePromise: Promise<typeof import("../infra/ssh-tunnel.js")> | undefined;
@@ -58,30 +51,27 @@ export async function gatewayStatusCommand(
const wideAreaDomain = resolveWideAreaDiscoveryDomain({
configDomain: cfg.discovery?.wideArea?.domain,
});
const baseTargets = resolveTargets(cfg, opts.url);
const network = buildNetworkHints(cfg);
const remotePort = resolveGatewayPort(cfg);
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
const discoveryPromise = discoverGatewayBeacons({
timeoutMs: discoveryTimeoutMs,
wideAreaDomain,
});
let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
let sshIdentity =
sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
const remotePort = resolveGatewayPort(cfg);
let sshTunnelError: string | null = null;
let sshTunnelStarted = false;
if (!sshTarget) {
sshTarget = inferSshTargetFromRemoteUrl(cfg.gateway?.remote?.url);
}
if (sshTarget) {
const resolved = await resolveSshTarget(sshTarget, sshIdentity, overallTimeoutMs);
const resolved = await resolveSshTarget({
rawTarget: sshTarget,
identity: sshIdentity,
overallTimeoutMs,
loadSshConfigModule,
loadSshTunnelModule,
});
if (resolved) {
sshTarget = resolved.target;
if (!sshIdentity && resolved.identity) {
@@ -90,372 +80,57 @@ export async function gatewayStatusCommand(
}
}
const { discovery, probed } = await withProgress(
const probePass = await withProgress(
{
label: "Inspecting gateways…",
indeterminate: true,
enabled: opts.json !== true,
},
async () => {
const tryStartTunnel = async () => {
if (!sshTarget) {
return null;
}
try {
const { startSshPortForward } = await loadSshTunnelModule();
const tunnel = await startSshPortForward({
target: sshTarget,
identity: sshIdentity ?? undefined,
localPortPreferred: remotePort,
remotePort,
timeoutMs: Math.min(1500, overallTimeoutMs),
});
sshTunnelStarted = true;
return tunnel;
} catch (err) {
sshTunnelError = err instanceof Error ? err.message : String(err);
return null;
}
};
const discoveryTask = discoveryPromise.catch(() => []);
const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null);
const [discovery, tunnelFirst] = await Promise.all([discoveryTask, tunnelTask]);
if (!sshTarget && opts.sshAuto) {
const user = process.env.USER?.trim() || "";
const candidates = discovery
.map((b) => {
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}` : host;
return sshPort !== 22 ? `${base}:${sshPort}` : base;
})
.filter((candidate): candidate is string => Boolean(candidate));
const { parseSshTarget } = await loadSshTunnelModule();
const validCandidates = candidates.filter((candidate) =>
Boolean(parseSshTarget(candidate)),
);
if (validCandidates.length > 0) {
sshTarget = validCandidates[0] ?? null;
}
}
const tunnel =
tunnelFirst ||
(sshTarget && !sshTunnelStarted && !sshTunnelError ? await tryStartTunnel() : null);
const tunnelTarget: GatewayStatusTarget | null = tunnel
? {
id: "sshTunnel",
kind: "sshTunnel",
url: `ws://127.0.0.1:${tunnel.localPort}`,
active: true,
tunnel: {
kind: "ssh",
target: sshTarget ?? "",
localPort: tunnel.localPort,
remotePort,
pid: tunnel.pid,
},
}
: null;
const targets: GatewayStatusTarget[] = tunnelTarget
? [tunnelTarget, ...baseTargets.filter((t) => t.url !== tunnelTarget.url)]
: baseTargets;
try {
const probed = await Promise.all(
targets.map(async (target) => {
const authResolution = await resolveAuthForTarget(cfg, target, {
token: typeof opts.token === "string" ? opts.token : undefined,
password: typeof opts.password === "string" ? opts.password : undefined,
});
const auth = {
token: authResolution.token,
password: authResolution.password,
};
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target);
const probe = await probeGateway({
url: target.url,
auth,
timeoutMs,
});
const configSummary = probe.configSnapshot
? extractConfigSummary(probe.configSnapshot)
: null;
const self = pickGatewaySelfPresence(probe.presence);
return {
target,
probe,
configSummary,
self,
authDiagnostics: authResolution.diagnostics ?? [],
};
}),
);
return { discovery, probed };
} finally {
if (tunnel) {
try {
await tunnel.stop();
} catch {
// best-effort
}
}
}
},
async () =>
await runGatewayStatusProbePass({
cfg,
opts,
overallTimeoutMs,
discoveryTimeoutMs,
wideAreaDomain,
baseTargets,
remotePort,
sshTarget,
sshIdentity,
loadSshTunnelModule,
}),
);
const reachable = probed.filter((p) => isProbeReachable(p.probe));
const ok = reachable.length > 0;
const degradedScopeLimited = probed.filter((p) => isScopeLimitedProbeFailure(p.probe));
const degraded = degradedScopeLimited.length > 0;
const multipleGateways = reachable.length > 1;
const primary =
reachable.find((p) => p.target.kind === "explicit") ??
reachable.find((p) => p.target.kind === "sshTunnel") ??
reachable.find((p) => p.target.kind === "configRemote") ??
reachable.find((p) => p.target.kind === "localLoopback") ??
null;
const warnings: Array<{
code: string;
message: string;
targetIds?: string[];
}> = [];
if (sshTarget && !sshTunnelStarted) {
warnings.push({
code: "ssh_tunnel_failed",
message: sshTunnelError
? `SSH tunnel failed: ${String(sshTunnelError)}`
: "SSH tunnel failed to start; falling back to direct probes.",
});
}
if (multipleGateways) {
warnings.push({
code: "multiple_gateways",
message:
"Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).",
targetIds: reachable.map((p) => p.target.id),
});
}
for (const result of probed) {
if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) {
continue;
}
for (const diagnostic of result.authDiagnostics) {
warnings.push({
code: "auth_secretref_unresolved",
message: diagnostic,
targetIds: [result.target.id],
});
}
}
for (const result of degradedScopeLimited) {
warnings.push({
code: "probe_scope_limited",
message:
"Probe diagnostics are limited by gateway scopes (missing operator.read). Connection succeeded, but status details may be incomplete. Hint: pair device identity or use credentials with operator.read.",
targetIds: [result.target.id],
});
}
const warnings = buildGatewayStatusWarnings({
probed: probePass.probed,
sshTarget: probePass.sshTarget,
sshTunnelStarted: probePass.sshTunnelStarted,
sshTunnelError: probePass.sshTunnelError,
});
const primary = pickPrimaryProbedTarget(probePass.probed);
if (opts.json) {
writeRuntimeJson(runtime, {
ok,
degraded,
ts: Date.now(),
durationMs: Date.now() - startedAt,
timeoutMs: overallTimeoutMs,
primaryTargetId: primary?.target.id ?? null,
warnings,
writeGatewayStatusJson({
runtime,
startedAt,
overallTimeoutMs,
discoveryTimeoutMs,
network,
discovery: {
timeoutMs: discoveryTimeoutMs,
count: discovery.length,
beacons: discovery.map((b) => ({
instanceName: b.instanceName,
displayName: b.displayName ?? null,
domain: b.domain ?? null,
host: b.host ?? null,
lanHost: b.lanHost ?? null,
tailnetDns: b.tailnetDns ?? null,
gatewayPort: b.gatewayPort ?? null,
sshPort: b.sshPort ?? null,
wsUrl: (() => {
const host = pickResolvedGatewayHost(b);
const port = pickResolvedGatewayPort(b);
return host && port ? `ws://${host}:${port}` : null;
})(),
})),
},
targets: probed.map((p) => ({
id: p.target.id,
kind: p.target.kind,
url: p.target.url,
active: p.target.active,
tunnel: p.target.tunnel ?? null,
connect: {
ok: isProbeReachable(p.probe),
rpcOk: p.probe.ok,
scopeLimited: isScopeLimitedProbeFailure(p.probe),
latencyMs: p.probe.connectLatencyMs,
error: p.probe.error,
close: p.probe.close,
},
self: p.self,
config: p.configSummary,
health: p.probe.health,
summary: p.probe.status,
presence: p.probe.presence,
})),
discovery: probePass.discovery,
probed: probePass.probed,
warnings,
primaryTargetId: primary?.target.id ?? null,
});
if (!ok) {
runtime.exit(1);
}
return;
}
runtime.log(colorize(rich, theme.heading, "Gateway Status"));
runtime.log(
ok
? `${colorize(rich, theme.success, "Reachable")}: yes`
: `${colorize(rich, theme.error, "Reachable")}: no`,
);
runtime.log(colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`));
if (warnings.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.warn, "Warning:"));
for (const w of warnings) {
runtime.log(`- ${w.message}`);
}
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Discovery (this machine)"));
const discoveryDomains = wideAreaDomain ? `local. + ${wideAreaDomain}` : "local.";
runtime.log(
discovery.length > 0
? `Found ${discovery.length} gateway(s) via Bonjour (${discoveryDomains})`
: `Found 0 gateways via Bonjour (${discoveryDomains})`,
);
if (discovery.length === 0) {
runtime.log(
colorize(
rich,
theme.muted,
"Tip: if the gateway is remote, mDNS wont cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.",
),
);
}
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Targets"));
for (const p of probed) {
runtime.log(renderTargetHeader(p.target, rich));
runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`);
if (p.target.tunnel?.kind === "ssh") {
runtime.log(
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, p.target.tunnel.target)}`,
);
}
if (p.probe.ok && p.self) {
const host = p.self.host ?? "unknown";
const ip = p.self.ip ? ` (${p.self.ip})` : "";
const platform = p.self.platform ? ` · ${p.self.platform}` : "";
const version = p.self.version ? ` · app ${p.self.version}` : "";
runtime.log(` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`);
}
if (p.configSummary) {
const c = p.configSummary;
const wideArea =
c.discovery.wideAreaEnabled === true
? "enabled"
: c.discovery.wideAreaEnabled === false
? "disabled"
: "unknown";
runtime.log(` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`);
}
runtime.log("");
}
if (!ok) {
runtime.exit(1);
}
}
function inferSshTargetFromRemoteUrl(rawUrl?: string | null): string | null {
if (typeof rawUrl !== "string") {
return null;
}
const trimmed = rawUrl.trim();
if (!trimmed) {
return null;
}
let host: string | null = null;
try {
host = new URL(trimmed).hostname || null;
} catch {
return null;
}
if (!host) {
return null;
}
const user = process.env.USER?.trim() || "";
return user ? `${user}@${host}` : host;
}
function buildSshTarget(input: { user?: string; host?: string; port?: number }): string | null {
const host = input.host?.trim() ?? "";
if (!host) {
return null;
}
const user = input.user?.trim() ?? "";
const base = user ? `${user}@${host}` : host;
const port = input.port ?? 22;
if (port && port !== 22) {
return `${base}:${port}`;
}
return base;
}
async function resolveSshTarget(
rawTarget: string,
identity: string | null,
overallTimeoutMs: number,
): Promise<{ target: string; identity?: string } | null> {
const [{ resolveSshConfig }, { parseSshTarget }] = await Promise.all([
loadSshConfigModule(),
loadSshTunnelModule(),
]);
const parsed = parseSshTarget(rawTarget);
if (!parsed) {
return null;
}
const config = await resolveSshConfig(parsed, {
identity: identity ?? undefined,
timeoutMs: Math.min(800, overallTimeoutMs),
writeGatewayStatusText({
runtime,
rich,
overallTimeoutMs,
wideAreaDomain,
discovery: probePass.discovery,
probed: probePass.probed,
warnings,
});
if (!config) {
return { target: rawTarget, identity: identity ?? undefined };
}
const target = buildSshTarget({
user: config.user ?? parsed.user,
host: config.host ?? parsed.host,
port: config.port ?? parsed.port,
});
if (!target) {
return { target: rawTarget, identity: identity ?? undefined };
}
const identityFile =
identity ?? config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ?? undefined;
return { target, identity: identityFile };
}

View File

@@ -0,0 +1,102 @@
import type { GatewayBonjourBeacon } from "../../infra/bonjour-discovery.js";
import {
buildGatewayDiscoveryTarget,
serializeGatewayDiscoveryBeacon,
} from "../../infra/gateway-discovery-targets.js";
export function inferSshTargetFromRemoteUrl(rawUrl?: string | null): string | null {
if (typeof rawUrl !== "string") {
return null;
}
const trimmed = rawUrl.trim();
if (!trimmed) {
return null;
}
let host: string | null = null;
try {
host = new URL(trimmed).hostname || null;
} catch {
return null;
}
if (!host) {
return null;
}
const user = process.env.USER?.trim() || "";
return user ? `${user}@${host}` : host;
}
export function buildSshTarget(input: {
user?: string;
host?: string;
port?: number;
}): string | null {
const host = input.host?.trim() ?? "";
if (!host) {
return null;
}
const user = input.user?.trim() ?? "";
const base = user ? `${user}@${host}` : host;
const port = input.port ?? 22;
if (port && port !== 22) {
return `${base}:${port}`;
}
return base;
}
export async function resolveSshTarget(params: {
rawTarget: string;
identity: string | null;
overallTimeoutMs: number;
loadSshConfigModule: () => Promise<typeof import("../../infra/ssh-config.js")>;
loadSshTunnelModule: () => Promise<typeof import("../../infra/ssh-tunnel.js")>;
}): Promise<{ target: string; identity?: string } | null> {
const [{ resolveSshConfig }, { parseSshTarget }] = await Promise.all([
params.loadSshConfigModule(),
params.loadSshTunnelModule(),
]);
const parsed = parseSshTarget(params.rawTarget);
if (!parsed) {
return null;
}
const config = await resolveSshConfig(parsed, {
identity: params.identity ?? undefined,
timeoutMs: Math.min(800, params.overallTimeoutMs),
});
if (!config) {
return { target: params.rawTarget, identity: params.identity ?? undefined };
}
const target = buildSshTarget({
user: config.user ?? parsed.user,
host: config.host ?? parsed.host,
port: config.port ?? parsed.port,
});
if (!target) {
return { target: params.rawTarget, identity: params.identity ?? undefined };
}
const identityFile =
params.identity ??
config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ??
undefined;
return { target, identity: identityFile };
}
export function pickAutoSshTargetFromDiscovery(params: {
discovery: GatewayBonjourBeacon[];
parseSshTarget: (target: string) => unknown;
sshUser?: string | null;
}): string | null {
for (const beacon of params.discovery) {
const sshTarget = buildGatewayDiscoveryTarget(beacon, {
sshUser: params.sshUser ?? undefined,
}).sshTarget;
if (!sshTarget) {
continue;
}
if (params.parseSshTarget(sshTarget)) {
return sshTarget;
}
}
return null;
}
export { serializeGatewayDiscoveryBeacon };

View File

@@ -0,0 +1,216 @@
import type { RuntimeEnv } from "../../runtime.js";
import { writeRuntimeJson } from "../../runtime.js";
import { colorize, theme } from "../../terminal/theme.js";
import { serializeGatewayDiscoveryBeacon } from "./discovery.js";
import {
isProbeReachable,
isScopeLimitedProbeFailure,
renderProbeSummaryLine,
renderTargetHeader,
} from "./helpers.js";
import type { GatewayStatusProbedTarget } from "./probe-run.js";
export type GatewayStatusWarning = {
code: string;
message: string;
targetIds?: string[];
};
export function pickPrimaryProbedTarget(probed: GatewayStatusProbedTarget[]) {
const reachable = probed.filter((entry) => isProbeReachable(entry.probe));
return (
reachable.find((entry) => entry.target.kind === "explicit") ??
reachable.find((entry) => entry.target.kind === "sshTunnel") ??
reachable.find((entry) => entry.target.kind === "configRemote") ??
reachable.find((entry) => entry.target.kind === "localLoopback") ??
null
);
}
export function buildGatewayStatusWarnings(params: {
probed: GatewayStatusProbedTarget[];
sshTarget: string | null;
sshTunnelStarted: boolean;
sshTunnelError: string | null;
}): GatewayStatusWarning[] {
const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe));
const degradedScopeLimited = params.probed.filter((entry) =>
isScopeLimitedProbeFailure(entry.probe),
);
const warnings: GatewayStatusWarning[] = [];
if (params.sshTarget && !params.sshTunnelStarted) {
warnings.push({
code: "ssh_tunnel_failed",
message: params.sshTunnelError
? `SSH tunnel failed: ${String(params.sshTunnelError)}`
: "SSH tunnel failed to start; falling back to direct probes.",
});
}
if (reachable.length > 1) {
warnings.push({
code: "multiple_gateways",
message:
"Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).",
targetIds: reachable.map((entry) => entry.target.id),
});
}
for (const result of params.probed) {
if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) {
continue;
}
for (const diagnostic of result.authDiagnostics) {
warnings.push({
code: "auth_secretref_unresolved",
message: diagnostic,
targetIds: [result.target.id],
});
}
}
for (const result of degradedScopeLimited) {
warnings.push({
code: "probe_scope_limited",
message:
"Probe diagnostics are limited by gateway scopes (missing operator.read). Connection succeeded, but status details may be incomplete. Hint: pair device identity or use credentials with operator.read.",
targetIds: [result.target.id],
});
}
return warnings;
}
export function writeGatewayStatusJson(params: {
runtime: RuntimeEnv;
startedAt: number;
overallTimeoutMs: number;
discoveryTimeoutMs: number;
network: ReturnType<typeof import("./helpers.js").buildNetworkHints>;
discovery: Parameters<typeof serializeGatewayDiscoveryBeacon>[0][];
probed: GatewayStatusProbedTarget[];
warnings: GatewayStatusWarning[];
primaryTargetId: string | null;
}) {
const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe));
const degraded = params.probed.some((entry) => isScopeLimitedProbeFailure(entry.probe));
writeRuntimeJson(params.runtime, {
ok: reachable.length > 0,
degraded,
ts: Date.now(),
durationMs: Date.now() - params.startedAt,
timeoutMs: params.overallTimeoutMs,
primaryTargetId: params.primaryTargetId,
warnings: params.warnings,
network: params.network,
discovery: {
timeoutMs: params.discoveryTimeoutMs,
count: params.discovery.length,
beacons: params.discovery.map((beacon) => serializeGatewayDiscoveryBeacon(beacon)),
},
targets: params.probed.map((entry) => ({
id: entry.target.id,
kind: entry.target.kind,
url: entry.target.url,
active: entry.target.active,
tunnel: entry.target.tunnel ?? null,
connect: {
ok: isProbeReachable(entry.probe),
rpcOk: entry.probe.ok,
scopeLimited: isScopeLimitedProbeFailure(entry.probe),
latencyMs: entry.probe.connectLatencyMs,
error: entry.probe.error,
close: entry.probe.close,
},
self: entry.self,
config: entry.configSummary,
health: entry.probe.health,
summary: entry.probe.status,
presence: entry.probe.presence,
})),
});
if (reachable.length === 0) {
params.runtime.exit(1);
}
}
export function writeGatewayStatusText(params: {
runtime: RuntimeEnv;
rich: boolean;
overallTimeoutMs: number;
wideAreaDomain?: string | null;
discovery: Parameters<typeof serializeGatewayDiscoveryBeacon>[0][];
probed: GatewayStatusProbedTarget[];
warnings: GatewayStatusWarning[];
}) {
const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe));
const ok = reachable.length > 0;
params.runtime.log(colorize(params.rich, theme.heading, "Gateway Status"));
params.runtime.log(
ok
? `${colorize(params.rich, theme.success, "Reachable")}: yes`
: `${colorize(params.rich, theme.error, "Reachable")}: no`,
);
params.runtime.log(
colorize(params.rich, theme.muted, `Probe budget: ${params.overallTimeoutMs}ms`),
);
if (params.warnings.length > 0) {
params.runtime.log("");
params.runtime.log(colorize(params.rich, theme.warn, "Warning:"));
for (const warning of params.warnings) {
params.runtime.log(`- ${warning.message}`);
}
}
params.runtime.log("");
params.runtime.log(colorize(params.rich, theme.heading, "Discovery (this machine)"));
const discoveryDomains = params.wideAreaDomain ? `local. + ${params.wideAreaDomain}` : "local.";
params.runtime.log(
params.discovery.length > 0
? `Found ${params.discovery.length} gateway(s) via Bonjour (${discoveryDomains})`
: `Found 0 gateways via Bonjour (${discoveryDomains})`,
);
if (params.discovery.length === 0) {
params.runtime.log(
colorize(
params.rich,
theme.muted,
"Tip: if the gateway is remote, mDNS wont cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.",
),
);
}
params.runtime.log("");
params.runtime.log(colorize(params.rich, theme.heading, "Targets"));
for (const result of params.probed) {
params.runtime.log(renderTargetHeader(result.target, params.rich));
params.runtime.log(` ${renderProbeSummaryLine(result.probe, params.rich)}`);
if (result.target.tunnel?.kind === "ssh") {
params.runtime.log(
` ${colorize(params.rich, theme.muted, "ssh")}: ${colorize(params.rich, theme.command, result.target.tunnel.target)}`,
);
}
if (result.probe.ok && result.self) {
const host = result.self.host ?? "unknown";
const ip = result.self.ip ? ` (${result.self.ip})` : "";
const platform = result.self.platform ? ` · ${result.self.platform}` : "";
const version = result.self.version ? ` · app ${result.self.version}` : "";
params.runtime.log(
` ${colorize(params.rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`,
);
}
if (result.configSummary) {
const wideArea =
result.configSummary.discovery.wideAreaEnabled === true
? "enabled"
: result.configSummary.discovery.wideAreaEnabled === false
? "disabled"
: "unknown";
params.runtime.log(
` ${colorize(params.rich, theme.info, "Wide-area discovery")}: ${wideArea}`,
);
}
params.runtime.log("");
}
if (!ok) {
params.runtime.exit(1);
}
}

View File

@@ -0,0 +1,155 @@
import type { OpenClawConfig } from "../../config/types.js";
import { probeGateway } from "../../gateway/probe.js";
import {
discoverGatewayBeacons,
type GatewayBonjourBeacon,
} from "../../infra/bonjour-discovery.js";
import { pickAutoSshTargetFromDiscovery } from "./discovery.js";
import {
extractConfigSummary,
pickGatewaySelfPresence,
resolveAuthForTarget,
resolveProbeBudgetMs,
type GatewayConfigSummary,
type GatewayStatusTarget,
} from "./helpers.js";
export type GatewayStatusProbedTarget = {
target: GatewayStatusTarget;
probe: Awaited<ReturnType<typeof probeGateway>>;
configSummary: GatewayConfigSummary | null;
self: ReturnType<typeof pickGatewaySelfPresence>;
authDiagnostics: string[];
};
export async function runGatewayStatusProbePass(params: {
cfg: OpenClawConfig;
opts: {
token?: string;
password?: string;
sshAuto?: boolean;
};
overallTimeoutMs: number;
discoveryTimeoutMs: number;
wideAreaDomain?: string | null;
baseTargets: GatewayStatusTarget[];
remotePort: number;
sshTarget: string | null;
sshIdentity: string | null;
loadSshTunnelModule: () => Promise<typeof import("../../infra/ssh-tunnel.js")>;
}): Promise<{
discovery: GatewayBonjourBeacon[];
probed: GatewayStatusProbedTarget[];
sshTarget: string | null;
sshTunnelStarted: boolean;
sshTunnelError: string | null;
}> {
const discoveryPromise = discoverGatewayBeacons({
timeoutMs: params.discoveryTimeoutMs,
wideAreaDomain: params.wideAreaDomain,
});
let sshTarget = params.sshTarget;
let sshTunnelError: string | null = null;
let sshTunnelStarted = false;
const tryStartTunnel = async () => {
if (!sshTarget) {
return null;
}
try {
const { startSshPortForward } = await params.loadSshTunnelModule();
const tunnel = await startSshPortForward({
target: sshTarget,
identity: params.sshIdentity ?? undefined,
localPortPreferred: params.remotePort,
remotePort: params.remotePort,
timeoutMs: Math.min(1500, params.overallTimeoutMs),
});
sshTunnelStarted = true;
return tunnel;
} catch (err) {
sshTunnelError = err instanceof Error ? err.message : String(err);
return null;
}
};
const discoveryTask = discoveryPromise.catch(() => []);
const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null);
const [discovery, tunnelFirst] = await Promise.all([discoveryTask, tunnelTask]);
if (!sshTarget && params.opts.sshAuto) {
const { parseSshTarget } = await params.loadSshTunnelModule();
sshTarget = pickAutoSshTargetFromDiscovery({
discovery,
parseSshTarget,
sshUser: process.env.USER?.trim() || "",
});
}
const tunnel =
tunnelFirst ||
(sshTarget && !sshTunnelStarted && !sshTunnelError ? await tryStartTunnel() : null);
const tunnelTarget: GatewayStatusTarget | null = tunnel
? {
id: "sshTunnel",
kind: "sshTunnel",
url: `ws://127.0.0.1:${tunnel.localPort}`,
active: true,
tunnel: {
kind: "ssh",
target: sshTarget ?? "",
localPort: tunnel.localPort,
remotePort: params.remotePort,
pid: tunnel.pid,
},
}
: null;
const targets: GatewayStatusTarget[] = tunnelTarget
? [tunnelTarget, ...params.baseTargets.filter((target) => target.url !== tunnelTarget.url)]
: params.baseTargets;
try {
const probed = await Promise.all(
targets.map(async (target) => {
const authResolution = await resolveAuthForTarget(params.cfg, target, {
token: typeof params.opts.token === "string" ? params.opts.token : undefined,
password: typeof params.opts.password === "string" ? params.opts.password : undefined,
});
const probe = await probeGateway({
url: target.url,
auth: {
token: authResolution.token,
password: authResolution.password,
},
timeoutMs: resolveProbeBudgetMs(params.overallTimeoutMs, target),
});
return {
target,
probe,
configSummary: probe.configSnapshot ? extractConfigSummary(probe.configSnapshot) : null,
self: pickGatewaySelfPresence(probe.presence),
authDiagnostics: authResolution.diagnostics ?? [],
};
}),
);
return {
discovery,
probed,
sshTarget,
sshTunnelStarted,
sshTunnelError,
};
} finally {
if (tunnel) {
try {
await tunnel.stop();
} catch {
// best-effort
}
}
}
}

View File

@@ -1,12 +1,11 @@
import type { OpenClawConfig } from "../config/config.js";
import type { SecretInput } from "../config/types.secrets.js";
import { isSecureWebSocketUrl } from "../gateway/net.js";
import { discoverGatewayBeacons, type GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import {
discoverGatewayBeacons,
pickResolvedGatewayHost,
pickResolvedGatewayPort,
type GatewayBonjourBeacon,
} from "../infra/bonjour-discovery.js";
buildGatewayDiscoveryLabel,
buildGatewayDiscoveryTarget,
} from "../infra/gateway-discovery-targets.js";
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js";
import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js";
@@ -16,22 +15,8 @@ import type { SecretInputMode } from "./onboard-types.js";
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 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);
const port = pickPort(beacon);
const title = beacon.displayName ?? beacon.instanceName;
const hint = host && port ? `${host}:${port}` : "host unknown";
return `${title} (${hint})`;
return buildGatewayDiscoveryLabel(beacon);
}
function ensureWsUrl(value: string): string {
@@ -113,9 +98,9 @@ export async function promptRemoteGatewayConfig(
}
if (selectedBeacon) {
const host = pickHost(selectedBeacon);
const port = pickPort(selectedBeacon);
if (host && port) {
const target = buildGatewayDiscoveryTarget(selectedBeacon);
if (target.endpoint) {
const { host, port } = target.endpoint;
const mode = await prompter.select({
message: "Connection method",
options: [
@@ -141,9 +126,7 @@ export async function promptRemoteGatewayConfig(
await prompter.note(
[
"Start a tunnel before using the CLI:",
`ssh -N -L 18789:127.0.0.1:18789 <user>@${host}${
selectedBeacon.sshPort ? ` -p ${selectedBeacon.sshPort}` : ""
}`,
`ssh -N -L 18789:127.0.0.1:18789 <user>@${host}${target.sshPort ? ` -p ${target.sshPort}` : ""}`,
"Docs: https://docs.openclaw.ai/gateway/remote",
].join("\n"),
"SSH tunnel",

View File

@@ -25,6 +25,7 @@ export type ChatAbortTestContext = Record<string, unknown> & {
chatAbortControllers: Map<string, ReturnType<typeof createActiveRun>>;
chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>;
chatDeltaLastBroadcastLen: Map<string, number>;
chatAbortedRuns: Map<string, number>;
removeChatRun: (...args: unknown[]) => { sessionKey: string; clientRunId: string } | undefined;
agentRunSeq: Map<string, number>;
@@ -42,6 +43,7 @@ export function createChatAbortContext(
chatAbortControllers: new Map(),
chatRunBuffers: new Map(),
chatDeltaSentAt: new Map(),
chatDeltaLastBroadcastLen: new Map(),
chatAbortedRuns: new Map<string, number>(),
removeChatRun: vi
.fn()

View File

@@ -14,8 +14,10 @@ export function setRegistry(registry: PluginRegistry) {
}
vi.mock("./server-plugins.js", async () => {
const actual = await vi.importActual<typeof import("./server-plugins.js")>("./server-plugins.js");
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
return {
...actual,
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
setActivePluginRegistry(registryState.registry);
return {
@@ -23,7 +25,6 @@ vi.mock("./server-plugins.js", async () => {
gatewayMethods: params.baseMethods ?? [],
};
},
// server.impl.ts sets a fallback context before dispatch; tests only need the symbol to exist.
setFallbackGatewayContext: vi.fn(),
setFallbackGatewayContextResolver: vi.fn(),
};
});

View File

@@ -20,14 +20,41 @@ export type GatewayBonjourBeacon = {
txt?: Record<string, string>;
};
export function pickResolvedGatewayHost(beacon: GatewayBonjourBeacon): string | null {
export type GatewayDiscoveryResolvedEndpoint = {
host: string;
port: number;
gatewayTls: boolean;
gatewayTlsFingerprintSha256?: string;
scheme: "ws" | "wss";
wsUrl: string;
};
export function resolveGatewayDiscoveryEndpoint(
beacon: GatewayBonjourBeacon,
): GatewayDiscoveryResolvedEndpoint | null {
const host = beacon.host?.trim();
return host ? host : null;
const port = beacon.port;
if (!host || typeof port !== "number" || !Number.isFinite(port) || port <= 0) {
return null;
}
const gatewayTls = beacon.gatewayTls === true;
const scheme = gatewayTls ? "wss" : "ws";
return {
host,
port,
gatewayTls,
gatewayTlsFingerprintSha256: beacon.gatewayTlsFingerprintSha256,
scheme,
wsUrl: `${scheme}://${host}:${port}`,
};
}
export function pickResolvedGatewayHost(beacon: GatewayBonjourBeacon): string | null {
return resolveGatewayDiscoveryEndpoint(beacon)?.host ?? null;
}
export function pickResolvedGatewayPort(beacon: GatewayBonjourBeacon): number | null {
const port = beacon.port;
return typeof port === "number" && Number.isFinite(port) && port > 0 ? port : null;
return resolveGatewayDiscoveryEndpoint(beacon)?.port ?? null;
}
export type GatewayBonjourDiscoverOpts = {

View File

@@ -0,0 +1,61 @@
import {
resolveGatewayDiscoveryEndpoint,
type GatewayBonjourBeacon,
type GatewayDiscoveryResolvedEndpoint,
} from "./bonjour-discovery.js";
export type GatewayDiscoveryTarget = {
title: string;
domain: string;
endpoint: GatewayDiscoveryResolvedEndpoint | null;
wsUrl: string | null;
sshPort: number | null;
sshTarget: string | null;
};
function pickSshPort(beacon: GatewayBonjourBeacon): number | null {
return typeof beacon.sshPort === "number" && Number.isFinite(beacon.sshPort) && beacon.sshPort > 0
? beacon.sshPort
: null;
}
export function buildGatewayDiscoveryTarget(
beacon: GatewayBonjourBeacon,
opts?: { sshUser?: string | null },
): GatewayDiscoveryTarget {
const endpoint = resolveGatewayDiscoveryEndpoint(beacon);
const sshPort = pickSshPort(beacon);
const sshUser = opts?.sshUser?.trim() ?? "";
const baseSshTarget = endpoint ? (sshUser ? `${sshUser}@${endpoint.host}` : endpoint.host) : null;
const sshTarget =
baseSshTarget && sshPort && sshPort !== 22 ? `${baseSshTarget}:${sshPort}` : baseSshTarget;
return {
title: (beacon.displayName || beacon.instanceName || "Gateway").trim(),
domain: (beacon.domain || "local.").trim(),
endpoint,
wsUrl: endpoint?.wsUrl ?? null,
sshPort,
sshTarget,
};
}
export function buildGatewayDiscoveryLabel(beacon: GatewayBonjourBeacon): string {
const target = buildGatewayDiscoveryTarget(beacon);
const hint = target.endpoint ? `${target.endpoint.host}:${target.endpoint.port}` : "host unknown";
return `${target.title} (${hint})`;
}
export function serializeGatewayDiscoveryBeacon(beacon: GatewayBonjourBeacon) {
const target = buildGatewayDiscoveryTarget(beacon);
return {
instanceName: beacon.instanceName,
displayName: beacon.displayName ?? null,
domain: beacon.domain ?? null,
host: beacon.host ?? null,
lanHost: beacon.lanHost ?? null,
tailnetDns: beacon.tailnetDns ?? null,
gatewayPort: beacon.gatewayPort ?? null,
sshPort: beacon.sshPort ?? null,
wsUrl: target.wsUrl,
};
}