diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 036e91d63..83d0571c5 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -71,6 +71,9 @@ export function createGatewayTool(opts?: { typeof params.reason === "string" && params.reason.trim() ? params.reason.trim().slice(0, 200) : undefined; + console.info( + `gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`, + ); const scheduled = scheduleGatewaySigusr1Restart({ delayMs, reason, diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 4c3e49757..ed853cb08 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -22,13 +22,17 @@ import { setGatewayWsLogStyle, } from "../gateway/ws-logging.js"; import { setVerbose } from "../globals.js"; +import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js"; +import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; +import { WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js"; import { createSubsystemLogger, setConsoleSubsystemFilter, } from "../logging.js"; import { defaultRuntime } from "../runtime.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; import { forceFreePortAndWait } from "./ports.js"; import { withProgress } from "./progress.js"; @@ -87,6 +91,103 @@ const toOptionString = (value: unknown): string | undefined => { return undefined; }; +type GatewayDiscoverOpts = { + timeout?: string; + json?: boolean; +}; + +function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number { + if (raw === undefined || raw === null) return fallbackMs; + const value = typeof raw === "string" ? raw.trim() : String(raw); + if (!value) return fallbackMs; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`invalid --timeout: ${value}`); + } + return parsed; +} + +function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null { + const host = beacon.tailnetDns || beacon.lanHost || beacon.host; + return host?.trim() ? host.trim() : null; +} + +function pickGatewayPort(beacon: GatewayBonjourBeacon): number { + const port = beacon.gatewayPort ?? 18789; + return port > 0 ? port : 18789; +} + +function dedupeBeacons( + beacons: GatewayBonjourBeacon[], +): GatewayBonjourBeacon[] { + const out: GatewayBonjourBeacon[] = []; + const seen = new Set(); + for (const b of beacons) { + const host = pickBeaconHost(b) ?? ""; + const key = [ + b.domain ?? "", + b.instanceName ?? "", + b.displayName ?? "", + host, + String(b.port ?? ""), + String(b.bridgePort ?? ""), + String(b.gatewayPort ?? ""), + ].join("|"); + if (seen.has(key)) continue; + seen.add(key); + out.push(b); + } + return out; +} + +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 parts: string[] = []; + if (beacon.tailnetDns) + parts.push( + `${colorize(rich, theme.info, "tailnet")}: ${beacon.tailnetDns}`, + ); + if (beacon.lanHost) + parts.push(`${colorize(rich, theme.info, "lan")}: ${beacon.lanHost}`); + if (beacon.host) + parts.push(`${colorize(rich, theme.info, "host")}: ${beacon.host}`); + + const host = pickBeaconHost(beacon); + const gatewayPort = pickGatewayPort(beacon); + const wsUrl = host ? `ws://${host}:${gatewayPort}` : null; + + const firstLine = + parts.length > 0 + ? `${title} ${domain} · ${parts.join(" · ")}` + : `${title} ${domain}`; + + const lines = [`- ${firstLine}`]; + if (wsUrl) { + lines.push( + ` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`, + ); + } + if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) { + const ssh = `ssh -N -L 18789:127.0.0.1:18789 @${host} -p ${beacon.sshPort}`; + lines.push( + ` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`, + ); + } + return lines; +} + function describeUnknownError(err: unknown): string { if (err instanceof Error) return err.message; if (typeof err === "string") return err; @@ -219,9 +320,18 @@ async function runGatewayLoop(params: { })(); }; - const onSigterm = () => request("stop", "SIGTERM"); - const onSigint = () => request("stop", "SIGINT"); - const onSigusr1 = () => request("restart", "SIGUSR1"); + const onSigterm = () => { + gatewayLog.info("signal SIGTERM received"); + request("stop", "SIGTERM"); + }; + const onSigint = () => { + gatewayLog.info("signal SIGINT received"); + request("stop", "SIGINT"); + }; + const onSigusr1 = () => { + gatewayLog.info("signal SIGUSR1 received"); + request("restart", "SIGUSR1"); + }; process.on("SIGTERM", onSigterm); process.on("SIGINT", onSigint); @@ -658,4 +768,75 @@ export function registerGatewayCli(program: Command) { } }), ); + + gateway + .command("discover") + .description( + `Discover gateways via Bonjour (multicast local. + unicast ${WIDE_AREA_DISCOVERY_DOMAIN})`, + ) + .option("--timeout ", "Per-command timeout in ms", "2000") + .option("--json", "Output JSON", false) + .action(async (opts: GatewayDiscoverOpts) => { + try { + const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000); + const beacons = await withProgress( + { + label: "Scanning for gateways…", + indeterminate: true, + enabled: opts.json !== true, + }, + async () => await discoverGatewayBeacons({ timeoutMs }), + ); + + const deduped = dedupeBeacons(beacons).sort((a, b) => + String(a.displayName || a.instanceName).localeCompare( + String(b.displayName || b.instanceName), + ), + ); + + if (opts.json) { + const enriched = deduped.map((b) => { + const host = pickBeaconHost(b); + const port = pickGatewayPort(b); + return { + ...b, + wsUrl: host ? `ws://${host}:${port}` : null, + }; + }); + defaultRuntime.log( + JSON.stringify( + { + timeoutMs, + domains: ["local.", WIDE_AREA_DISCOVERY_DOMAIN], + count: enriched.length, + beacons: enriched, + }, + null, + 2, + ), + ); + return; + } + + const rich = isRich(); + defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery")); + defaultRuntime.log( + colorize( + rich, + theme.muted, + `Found ${deduped.length} gateway(s) · domains: local., ${WIDE_AREA_DISCOVERY_DOMAIN}`, + ), + ); + if (deduped.length === 0) return; + + for (const beacon of deduped) { + for (const line of renderBeaconLines(beacon, rich)) { + defaultRuntime.log(line); + } + } + } catch (err) { + defaultRuntime.error(`gateway discover failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }); } diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 6b998cbe5..72b459d4e 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -308,6 +308,9 @@ export function startGatewayConfigReloader(opts: { settings = resolveGatewayReloadSettings(nextConfig); if (changedPaths.length === 0) return; + opts.log.info( + `config change detected; evaluating reload (${changedPaths.join(", ")})`, + ); const plan = buildGatewayReloadPlan(changedPaths); if (settings.mode === "off") { opts.log.info("config reload disabled (gateway.reload.mode=off)"); diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts index 2f499b245..47eb85154 100644 --- a/src/macos/gateway-daemon.ts +++ b/src/macos/gateway-daemon.ts @@ -149,9 +149,18 @@ async function main() { })(); }; - const onSigterm = () => request("stop", "SIGTERM"); - const onSigint = () => request("stop", "SIGINT"); - const onSigusr1 = () => request("restart", "SIGUSR1"); + const onSigterm = () => { + defaultRuntime.log("gateway: signal SIGTERM received"); + request("stop", "SIGTERM"); + }; + const onSigint = () => { + defaultRuntime.log("gateway: signal SIGINT received"); + request("stop", "SIGINT"); + }; + const onSigusr1 = () => { + defaultRuntime.log("gateway: signal SIGUSR1 received"); + request("restart", "SIGUSR1"); + }; process.on("SIGTERM", onSigterm); process.on("SIGINT", onSigint);