mirror of https://github.com/openclaw/openclaw.git
fix(gateway): keep status helpers resilient to netif failures
This commit is contained in:
parent
c0cbc7403b
commit
8c7d603f25
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue