mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 07:57:40 +00:00
refactor(gateway): centralize discovery target handling
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 won’t 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 };
|
||||
}
|
||||
|
||||
102
src/commands/gateway-status/discovery.ts
Normal file
102
src/commands/gateway-status/discovery.ts
Normal 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 };
|
||||
216
src/commands/gateway-status/output.ts
Normal file
216
src/commands/gateway-status/output.ts
Normal 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 won’t 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);
|
||||
}
|
||||
}
|
||||
155
src/commands/gateway-status/probe-run.ts
Normal file
155
src/commands/gateway-status/probe-run.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
61
src/infra/gateway-discovery-targets.ts
Normal file
61
src/infra/gateway-discovery-targets.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user