* fix(gateway): use LAN IP for WebSocket/probe URLs when bind=lan (#11329) When gateway.bind=lan, the HTTP server correctly binds to 0.0.0.0 (all interfaces), but WebSocket connection URLs, probe targets, and Control UI links were hardcoded to 127.0.0.1. This caused CLI commands and status probes to show localhost-only URLs even in LAN mode, and made onboarding display misleading connection info. - Add pickPrimaryLanIPv4() to gateway/net.ts to detect the machine's primary LAN IPv4 address (prefers en0/eth0, falls back to any external interface) - Update pickProbeHostForBind() to use LAN IP when bind=lan - Update buildGatewayConnectionDetails() to use LAN IP and report "local lan <ip>" as the URL source - Update resolveControlUiLinks() to return LAN-accessible URLs - Update probe note in status.gather.ts to reflect new behavior - Add tests for pickPrimaryLanIPv4 and bind=lan URL resolution Closes #11329 Co-authored-by: Cursor <cursoragent@cursor.com> * test: move vi.restoreAllMocks to afterEach in pickPrimaryLanIPv4 Per review feedback: avoid calling vi.restoreAllMocks() inside individual tests as it restores all spies globally and can cause ordering issues. Use afterEach in the describe block instead. Co-authored-by: Cursor <cursoragent@cursor.com> * Changelog: note LAN bind URLs fix (#11448) (thanks @AnonO6) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
299 lines
9.3 KiB
TypeScript
299 lines
9.3 KiB
TypeScript
import type { GatewayBindMode, GatewayControlUiConfig } from "../../config/types.js";
|
|
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
|
|
import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
|
|
import type { GatewayRpcOpts } from "./types.js";
|
|
import {
|
|
createConfigIO,
|
|
resolveConfigPath,
|
|
resolveGatewayPort,
|
|
resolveStateDir,
|
|
} from "../../config/config.js";
|
|
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
|
|
import { findExtraGatewayServices } from "../../daemon/inspect.js";
|
|
import { auditGatewayServiceConfig } from "../../daemon/service-audit.js";
|
|
import { resolveGatewayService } from "../../daemon/service.js";
|
|
import { resolveGatewayBindHost } from "../../gateway/net.js";
|
|
import {
|
|
formatPortDiagnostics,
|
|
inspectPortUsage,
|
|
type PortListener,
|
|
type PortUsageStatus,
|
|
} from "../../infra/ports.js";
|
|
import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
|
|
import { probeGatewayStatus } from "./probe.js";
|
|
import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js";
|
|
|
|
type ConfigSummary = {
|
|
path: string;
|
|
exists: boolean;
|
|
valid: boolean;
|
|
issues?: Array<{ path: string; message: string }>;
|
|
controlUi?: GatewayControlUiConfig;
|
|
};
|
|
|
|
type GatewayStatusSummary = {
|
|
bindMode: GatewayBindMode;
|
|
bindHost: string;
|
|
customBindHost?: string;
|
|
port: number;
|
|
portSource: "service args" | "env/config";
|
|
probeUrl: string;
|
|
probeNote?: string;
|
|
};
|
|
|
|
export type DaemonStatus = {
|
|
service: {
|
|
label: string;
|
|
loaded: boolean;
|
|
loadedText: string;
|
|
notLoadedText: string;
|
|
command?: {
|
|
programArguments: string[];
|
|
workingDirectory?: string;
|
|
environment?: Record<string, string>;
|
|
sourcePath?: string;
|
|
} | null;
|
|
runtime?: {
|
|
status?: string;
|
|
state?: string;
|
|
subState?: string;
|
|
pid?: number;
|
|
lastExitStatus?: number;
|
|
lastExitReason?: string;
|
|
lastRunResult?: string;
|
|
lastRunTime?: string;
|
|
detail?: string;
|
|
cachedLabel?: boolean;
|
|
missingUnit?: boolean;
|
|
};
|
|
configAudit?: ServiceConfigAudit;
|
|
};
|
|
config?: {
|
|
cli: ConfigSummary;
|
|
daemon?: ConfigSummary;
|
|
mismatch?: boolean;
|
|
};
|
|
gateway?: GatewayStatusSummary;
|
|
port?: {
|
|
port: number;
|
|
status: PortUsageStatus;
|
|
listeners: PortListener[];
|
|
hints: string[];
|
|
};
|
|
portCli?: {
|
|
port: number;
|
|
status: PortUsageStatus;
|
|
listeners: PortListener[];
|
|
hints: string[];
|
|
};
|
|
lastError?: string;
|
|
rpc?: {
|
|
ok: boolean;
|
|
error?: string;
|
|
url?: string;
|
|
};
|
|
extraServices: Array<{ label: string; detail: string; scope: string }>;
|
|
};
|
|
|
|
function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: boolean) {
|
|
if (status !== "busy") {
|
|
return false;
|
|
}
|
|
if (rpcOk === true) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export async function gatherDaemonStatus(
|
|
opts: {
|
|
rpc: GatewayRpcOpts;
|
|
probe: boolean;
|
|
deep?: boolean;
|
|
} & FindExtraGatewayServicesOptions,
|
|
): Promise<DaemonStatus> {
|
|
const service = resolveGatewayService();
|
|
const [loaded, command, runtime] = await Promise.all([
|
|
service.isLoaded({ env: process.env }).catch(() => false),
|
|
service.readCommand(process.env).catch(() => null),
|
|
service.readRuntime(process.env).catch((err) => ({ status: "unknown", detail: String(err) })),
|
|
]);
|
|
const configAudit = await auditGatewayServiceConfig({
|
|
env: process.env,
|
|
command,
|
|
});
|
|
|
|
const serviceEnv = command?.environment ?? undefined;
|
|
const mergedDaemonEnv = {
|
|
...(process.env as Record<string, string | undefined>),
|
|
...(serviceEnv ?? undefined),
|
|
} satisfies Record<string, string | undefined>;
|
|
|
|
const cliConfigPath = resolveConfigPath(process.env, resolveStateDir(process.env));
|
|
const daemonConfigPath = resolveConfigPath(
|
|
mergedDaemonEnv as NodeJS.ProcessEnv,
|
|
resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv),
|
|
);
|
|
|
|
const cliIO = createConfigIO({ env: process.env, configPath: cliConfigPath });
|
|
const daemonIO = createConfigIO({
|
|
env: mergedDaemonEnv,
|
|
configPath: daemonConfigPath,
|
|
});
|
|
|
|
const [cliSnapshot, daemonSnapshot] = await Promise.all([
|
|
cliIO.readConfigFileSnapshot().catch(() => null),
|
|
daemonIO.readConfigFileSnapshot().catch(() => null),
|
|
]);
|
|
const cliCfg = cliIO.loadConfig();
|
|
const daemonCfg = daemonIO.loadConfig();
|
|
|
|
const cliConfigSummary: ConfigSummary = {
|
|
path: cliSnapshot?.path ?? cliConfigPath,
|
|
exists: cliSnapshot?.exists ?? false,
|
|
valid: cliSnapshot?.valid ?? true,
|
|
...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}),
|
|
controlUi: cliCfg.gateway?.controlUi,
|
|
};
|
|
const daemonConfigSummary: ConfigSummary = {
|
|
path: daemonSnapshot?.path ?? daemonConfigPath,
|
|
exists: daemonSnapshot?.exists ?? false,
|
|
valid: daemonSnapshot?.valid ?? true,
|
|
...(daemonSnapshot?.issues?.length ? { issues: daemonSnapshot.issues } : {}),
|
|
controlUi: daemonCfg.gateway?.controlUi,
|
|
};
|
|
const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path;
|
|
|
|
const portFromArgs = parsePortFromArgs(command?.programArguments);
|
|
const daemonPort = portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv);
|
|
const portSource: GatewayStatusSummary["portSource"] = portFromArgs
|
|
? "service args"
|
|
: "env/config";
|
|
|
|
const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as
|
|
| "auto"
|
|
| "lan"
|
|
| "loopback"
|
|
| "custom"
|
|
| "tailnet";
|
|
const customBindHost = daemonCfg.gateway?.customBindHost;
|
|
const bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
|
|
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
|
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
|
|
const probeUrlOverride =
|
|
typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0 ? opts.rpc.url.trim() : null;
|
|
const probeUrl = probeUrlOverride ?? `ws://${probeHost}:${daemonPort}`;
|
|
const probeNote =
|
|
!probeUrlOverride && bindMode === "lan"
|
|
? `bind=lan listens on 0.0.0.0 (all interfaces); probing via ${probeHost}.`
|
|
: !probeUrlOverride && bindMode === "loopback"
|
|
? "Loopback-only gateway; only local clients can connect."
|
|
: undefined;
|
|
|
|
const cliPort = resolveGatewayPort(cliCfg, process.env);
|
|
const [portDiagnostics, portCliDiagnostics] = await Promise.all([
|
|
inspectPortUsage(daemonPort).catch(() => null),
|
|
cliPort !== daemonPort ? inspectPortUsage(cliPort).catch(() => null) : null,
|
|
]);
|
|
const portStatus: DaemonStatus["port"] | undefined = portDiagnostics
|
|
? {
|
|
port: portDiagnostics.port,
|
|
status: portDiagnostics.status,
|
|
listeners: portDiagnostics.listeners,
|
|
hints: portDiagnostics.hints,
|
|
}
|
|
: undefined;
|
|
const portCliStatus: DaemonStatus["portCli"] | undefined = portCliDiagnostics
|
|
? {
|
|
port: portCliDiagnostics.port,
|
|
status: portCliDiagnostics.status,
|
|
listeners: portCliDiagnostics.listeners,
|
|
hints: portCliDiagnostics.hints,
|
|
}
|
|
: undefined;
|
|
|
|
const extraServices = await findExtraGatewayServices(
|
|
process.env as Record<string, string | undefined>,
|
|
{ deep: Boolean(opts.deep) },
|
|
).catch(() => []);
|
|
|
|
const timeoutMsRaw = Number.parseInt(String(opts.rpc.timeout ?? "10000"), 10);
|
|
const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 10_000;
|
|
|
|
const rpc = opts.probe
|
|
? await probeGatewayStatus({
|
|
url: probeUrl,
|
|
token:
|
|
opts.rpc.token ||
|
|
mergedDaemonEnv.OPENCLAW_GATEWAY_TOKEN ||
|
|
daemonCfg.gateway?.auth?.token,
|
|
password:
|
|
opts.rpc.password ||
|
|
mergedDaemonEnv.OPENCLAW_GATEWAY_PASSWORD ||
|
|
daemonCfg.gateway?.auth?.password,
|
|
timeoutMs,
|
|
json: opts.rpc.json,
|
|
configPath: daemonConfigSummary.path,
|
|
})
|
|
: undefined;
|
|
|
|
let lastError: string | undefined;
|
|
if (loaded && runtime?.status === "running" && portStatus && portStatus.status !== "busy") {
|
|
lastError = (await readLastGatewayErrorLine(mergedDaemonEnv as NodeJS.ProcessEnv)) ?? undefined;
|
|
}
|
|
|
|
return {
|
|
service: {
|
|
label: service.label,
|
|
loaded,
|
|
loadedText: service.loadedText,
|
|
notLoadedText: service.notLoadedText,
|
|
command,
|
|
runtime,
|
|
configAudit,
|
|
},
|
|
config: {
|
|
cli: cliConfigSummary,
|
|
daemon: daemonConfigSummary,
|
|
...(configMismatch ? { mismatch: true } : {}),
|
|
},
|
|
gateway: {
|
|
bindMode,
|
|
bindHost,
|
|
customBindHost,
|
|
port: daemonPort,
|
|
portSource,
|
|
probeUrl,
|
|
...(probeNote ? { probeNote } : {}),
|
|
},
|
|
port: portStatus,
|
|
...(portCliStatus ? { portCli: portCliStatus } : {}),
|
|
lastError,
|
|
...(rpc ? { rpc: { ...rpc, url: probeUrl } } : {}),
|
|
extraServices,
|
|
};
|
|
}
|
|
|
|
export function renderPortDiagnosticsForCli(status: DaemonStatus, rpcOk?: boolean): string[] {
|
|
if (!status.port || !shouldReportPortUsage(status.port.status, rpcOk)) {
|
|
return [];
|
|
}
|
|
return formatPortDiagnostics({
|
|
port: status.port.port,
|
|
status: status.port.status,
|
|
listeners: status.port.listeners,
|
|
hints: status.port.hints,
|
|
});
|
|
}
|
|
|
|
export function resolvePortListeningAddresses(status: DaemonStatus): string[] {
|
|
const addrs = Array.from(
|
|
new Set(
|
|
status.port?.listeners
|
|
?.map((l) => (l.address ? normalizeListenerAddress(l.address) : ""))
|
|
.filter((v): v is string => Boolean(v)) ?? [],
|
|
),
|
|
);
|
|
return addrs;
|
|
}
|