fix(gateway): keep status helpers resilient to netif failures

This commit is contained in:
Codex 2026-03-22 22:36:48 +08:00 committed by Peter Steinberger
parent c0cbc7403b
commit 8c7d603f25
8 changed files with 176 additions and 8 deletions

View File

@ -215,6 +215,36 @@ describe("gatherDaemonStatus", () => {
expect(status.rpc?.url).toBe("wss://override.example:18790");
});
it("uses fallback network details when interface discovery throws during status inspection", async () => {
daemonLoadedConfig = {
gateway: {
bind: "tailnet",
tls: { enabled: true },
auth: { token: "daemon-token" },
},
};
resolveGatewayBindHost.mockImplementationOnce(async () => {
throw new Error("uv_interface_addresses failed");
});
pickPrimaryTailnetIPv4.mockImplementationOnce(() => {
throw new Error("uv_interface_addresses failed");
});
const status = await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
});
expect(status.gateway).toMatchObject({
bindMode: "tailnet",
bindHost: "127.0.0.1",
probeUrl: "wss://127.0.0.1:19001",
});
expect(status.gateway?.probeNote).toContain("interface discovery failed");
expect(status.gateway?.probeNote).toContain("tailnet addresses");
});
it("reuses command environment when reading runtime status", async () => {
serviceReadCommand.mockResolvedValueOnce({
programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"],

View File

@ -74,6 +74,37 @@ type ResolvedGatewayStatus = {
probeUrlOverride: string | null;
};
function summarizeDisplayNetworkError(error: unknown): string {
if (error instanceof Error) {
const message = error.message.trim();
if (message) {
return message;
}
}
return "network interface discovery failed";
}
function fallbackBindHostForStatus(bindMode: GatewayBindMode, customBindHost?: string): string {
if (bindMode === "lan") {
return "0.0.0.0";
}
if (bindMode === "custom") {
return customBindHost?.trim() || "0.0.0.0";
}
return "127.0.0.1";
}
function appendProbeNote(
existing: string | undefined,
extra: string | undefined,
): string | undefined {
const values = [existing, extra].filter((value): value is string => Boolean(value?.trim()));
if (values.length === 0) {
return undefined;
}
return [...new Set(values)].join(" ");
}
export type DaemonStatus = {
service: {
label: string;
@ -201,18 +232,34 @@ async function resolveGatewayStatusSummary(params: {
: "env/config";
const bindMode: GatewayBindMode = params.daemonCfg.gateway?.bind ?? "loopback";
const customBindHost = params.daemonCfg.gateway?.customBindHost;
const bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
const tailnetIPv4 = pickPrimaryTailnetIPv4();
let bindHost: string;
let networkWarning: string | undefined;
try {
bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
} catch (error) {
bindHost = fallbackBindHostForStatus(bindMode, customBindHost);
networkWarning = `Status is using fallback network details because interface discovery failed: ${summarizeDisplayNetworkError(error)}.`;
}
let tailnetIPv4: string | undefined;
try {
tailnetIPv4 = pickPrimaryTailnetIPv4();
} catch (error) {
networkWarning = appendProbeNote(
networkWarning,
`Status could not inspect tailnet addresses: ${summarizeDisplayNetworkError(error)}.`,
);
}
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
const probeUrlOverride = trimToUndefined(params.rpcUrlOverride) ?? null;
const scheme = params.daemonCfg.gateway?.tls?.enabled === true ? "wss" : "ws";
const probeUrl = probeUrlOverride ?? `${scheme}://${probeHost}:${daemonPort}`;
const probeNote =
let 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;
probeNote = appendProbeNote(probeNote, networkWarning);
return {
gateway: {

View File

@ -220,6 +220,24 @@ describe("gateway-status command", () => {
expect(targets[0]?.summary).toBeTruthy();
});
it("keeps status output working when tailnet discovery throws", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
pickPrimaryTailnetIPv4.mockImplementationOnce(() => {
throw new Error("uv_interface_addresses failed");
});
await runGatewayStatus(runtime, { timeout: "1000", json: true });
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
network?: { tailnetIPv4?: string | null; localTailnetUrl?: string | null };
};
expect(parsed.network).toMatchObject({
tailnetIPv4: null,
localTailnetUrl: null,
});
});
it("treats missing-scope RPC probe failures as degraded but reachable", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
readBestEffortConfig.mockResolvedValueOnce({

View File

@ -81,6 +81,14 @@ function normalizeWsUrl(value: string): string | null {
return trimmed;
}
function pickPrimaryTailnetIPv4ForStatus(): string | undefined {
try {
return pickPrimaryTailnetIPv4();
} catch {
return undefined;
}
}
export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): GatewayStatusTarget[] {
const targets: GatewayStatusTarget[] = [];
const add = (t: GatewayStatusTarget) => {
@ -310,7 +318,7 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
}
export function buildNetworkHints(cfg: OpenClawConfig) {
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const tailnetIPv4 = pickPrimaryTailnetIPv4ForStatus();
const port = resolveGatewayPort(cfg);
return {
localLoopbackUrl: `ws://127.0.0.1:${port}`,

View File

@ -1,3 +1,4 @@
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
normalizeGatewayTokenInput,
@ -112,6 +113,30 @@ describe("resolveControlUiLinks", () => {
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
it("falls back to loopback when tailnet discovery throws", () => {
mocks.pickPrimaryTailnetIPv4.mockImplementationOnce(() => {
throw new Error("uv_interface_addresses failed");
});
const links = resolveControlUiLinks({
port: 18789,
bind: "tailnet",
});
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
it("falls back to loopback when LAN discovery throws", () => {
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
throw new Error("uv_interface_addresses failed");
});
const links = resolveControlUiLinks({
port: 18789,
bind: "lan",
});
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
});
describe("normalizeGatewayTokenInput", () => {

View File

@ -456,6 +456,22 @@ function summarizeError(err: unknown): string {
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
function pickPrimaryTailnetIPv4ForDisplay(): string | undefined {
try {
return pickPrimaryTailnetIPv4();
} catch {
return undefined;
}
}
function pickPrimaryLanIPv4ForDisplay(): string | undefined {
try {
return pickPrimaryLanIPv4();
} catch {
return undefined;
}
}
export function resolveControlUiLinks(params: {
port: number;
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet";
@ -465,7 +481,7 @@ export function resolveControlUiLinks(params: {
const port = params.port;
const bind = params.bind ?? "loopback";
const customBindHost = params.customBindHost?.trim();
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const tailnetIPv4 = pickPrimaryTailnetIPv4ForDisplay();
const host = (() => {
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
return customBindHost;
@ -474,7 +490,7 @@ export function resolveControlUiLinks(params: {
return tailnetIPv4 ?? "127.0.0.1";
}
if (bind === "lan") {
return pickPrimaryLanIPv4() ?? "127.0.0.1";
return pickPrimaryLanIPv4ForDisplay() ?? "127.0.0.1";
}
return "127.0.0.1";
})();

View File

@ -45,7 +45,12 @@ function normalizePresenceKey(key: string | undefined): string | undefined {
}
function resolvePrimaryIPv4(): string | undefined {
return pickPrimaryLanIPv4() ?? os.hostname();
const host = os.hostname();
try {
return pickPrimaryLanIPv4() ?? host;
} catch {
return host;
}
}
function initSelfPresence() {

View File

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
async function withPresenceModule<T>(
@ -13,6 +14,10 @@ async function withPresenceModule<T>(
}
describe("system-presence version fallback", () => {
afterEach(() => {
vi.restoreAllMocks();
});
async function expectSelfVersion(
env: Record<string, string | undefined>,
expectedVersion: string | (() => Promise<string>),
@ -78,4 +83,18 @@ describe("system-presence version fallback", () => {
async () => (await import("../version.js")).VERSION,
);
});
it("falls back to hostname when self-presence LAN discovery throws", async () => {
await withEnvAsync({}, async () => {
vi.spyOn(os, "hostname").mockReturnValue("test-host");
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
throw new Error("uv_interface_addresses failed");
});
vi.resetModules();
const module = await import("./system-presence.js");
const selfEntry = module.listSystemPresence().find((entry) => entry.reason === "self");
expect(selfEntry?.host).toBe("test-host");
expect(selfEntry?.ip).toBe("test-host");
});
});
});