mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix: diagnose Windows LAN Gateway firewall blocks (#98666)
* Diagnose Windows LAN Gateway firewall blocks * Fix Windows firewall diagnostic lint * fix: gate gateway firewall diagnostics to local targets * fix: keep firewall inspection off critical flows
This commit is contained in:
@@ -22,6 +22,10 @@ If the Gateway is running on the same computer, open:
|
||||
|
||||
If the page fails to load, start the Gateway first: `openclaw gateway`.
|
||||
|
||||
<Note>
|
||||
On native Windows LAN binds, Windows Firewall or organization-managed Group Policy can still block the advertised LAN URL even when `127.0.0.1` works on the Gateway host. Run `openclaw gateway status --deep` on the Windows host; it reports likely blocked ports, profile mismatches, and local firewall rules that policy may ignore.
|
||||
</Note>
|
||||
|
||||
Auth is supplied during the WebSocket handshake via:
|
||||
|
||||
- `connect.params.auth.token`
|
||||
|
||||
@@ -59,6 +59,13 @@ const loadInstalledPluginIndexInstallRecords = vi.fn<
|
||||
const readGatewayRestartHandoffSync = vi.fn<
|
||||
(_env?: NodeJS.ProcessEnv) => GatewayRestartHandoff | null
|
||||
>(() => null);
|
||||
const inspectWindowsGatewayFirewall = vi.fn<(opts?: unknown) => Promise<unknown>>(async () => ({
|
||||
applies: false,
|
||||
severity: "info" as const,
|
||||
code: "windows_firewall_not_applicable",
|
||||
message: "Windows LAN firewall diagnostics do not apply.",
|
||||
details: [],
|
||||
}));
|
||||
const auditGatewayServiceConfig = vi.fn(async (_opts?: unknown) => undefined);
|
||||
const serviceIsLoaded = vi.fn(async (_opts?: unknown) => true);
|
||||
const serviceReadRuntime = vi.fn<
|
||||
@@ -224,6 +231,10 @@ vi.mock("../../infra/tls/gateway.js", () => ({
|
||||
loadGatewayTlsRuntime: (cfg: unknown) => loadGatewayTlsRuntime(cfg),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/windows-gateway-firewall-diagnostics.js", () => ({
|
||||
inspectWindowsGatewayFirewall: (opts: unknown) => inspectWindowsGatewayFirewall(opts),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeGatewayStatus: (opts: unknown) => callGatewayStatusProbe(opts),
|
||||
}));
|
||||
@@ -281,6 +292,14 @@ describe("gatherDaemonStatus", () => {
|
||||
loadGatewayTlsRuntime.mockClear();
|
||||
inspectGatewayRestart.mockClear();
|
||||
inspectPortConnections.mockClear();
|
||||
inspectWindowsGatewayFirewall.mockClear();
|
||||
inspectWindowsGatewayFirewall.mockResolvedValue({
|
||||
applies: false,
|
||||
severity: "info",
|
||||
code: "windows_firewall_not_applicable",
|
||||
message: "Windows LAN firewall diagnostics do not apply.",
|
||||
details: [],
|
||||
});
|
||||
readGatewayRestartHandoffSync.mockClear();
|
||||
readConfigFileSnapshotCalls.mockClear();
|
||||
loadConfigCalls.mockClear();
|
||||
@@ -335,6 +354,31 @@ describe("gatherDaemonStatus", () => {
|
||||
expect(status.cli?.entrypoint).toBe(process.argv[1]);
|
||||
}
|
||||
expect(inspectGatewayRestart).not.toHaveBeenCalled();
|
||||
expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes Windows firewall diagnostics during deep LAN gateway status", async () => {
|
||||
inspectWindowsGatewayFirewall.mockResolvedValueOnce({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_local_rules_ignored",
|
||||
message: "Windows Firewall may ignore local Gateway allow rules for this network profile.",
|
||||
details: ["Windows reports LocalFirewallRules as N/A (GPO-store only)."],
|
||||
});
|
||||
|
||||
const status = await gatherDaemonStatus({
|
||||
rpc: {},
|
||||
probe: false,
|
||||
deep: true,
|
||||
});
|
||||
|
||||
expect(inspectWindowsGatewayFirewall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bind: "lan", mode: "quick", port: 19001 }),
|
||||
);
|
||||
expect(status.gateway?.windowsFirewall).toMatchObject({
|
||||
severity: "warning",
|
||||
code: "windows_firewall_local_rules_ignored",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to probe version when server metadata is unavailable", async () => {
|
||||
@@ -653,6 +697,7 @@ describe("gatherDaemonStatus", () => {
|
||||
daemonLoadedConfig = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
bind: "lan",
|
||||
remote: { url: "wss://gateway.example" },
|
||||
},
|
||||
};
|
||||
@@ -664,6 +709,7 @@ describe("gatherDaemonStatus", () => {
|
||||
});
|
||||
|
||||
expect(inspectPortConnections).not.toHaveBeenCalled();
|
||||
expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
|
||||
expect(loadInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
|
||||
expect(status.connections).toBeUndefined();
|
||||
expect(status.pluginVersionDrift).toBeUndefined();
|
||||
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
readGatewayRestartHandoffSync,
|
||||
type GatewayRestartHandoff,
|
||||
} from "../../infra/restart-handoff.js";
|
||||
import {
|
||||
inspectWindowsGatewayFirewall,
|
||||
type WindowsGatewayFirewallDiagnostic,
|
||||
} from "../../infra/windows-gateway-firewall-diagnostics.js";
|
||||
import { resolveConfiguredLogFilePath } from "../../logging/log-file-path.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-record-reader.js";
|
||||
import {
|
||||
@@ -77,6 +81,7 @@ type GatewayStatusSummary = {
|
||||
controlUiLinks?: { httpUrl: string; wsUrl: string };
|
||||
probeNote?: string;
|
||||
version?: string | null;
|
||||
windowsFirewall?: WindowsGatewayFirewallDiagnostic;
|
||||
};
|
||||
|
||||
type PortStatusSummary = {
|
||||
@@ -599,6 +604,16 @@ export async function gatherDaemonStatus(
|
||||
commandProgramArguments: command?.programArguments,
|
||||
rpcUrlOverride: opts.rpc.url,
|
||||
});
|
||||
const shouldInspectLocalGateway = daemonCfg.gateway?.mode !== "remote" && !probeUrlOverride;
|
||||
const windowsFirewall =
|
||||
opts.deep === true && shouldInspectLocalGateway
|
||||
? await inspectWindowsGatewayFirewall({
|
||||
bind: gateway.bindMode,
|
||||
mode: "quick",
|
||||
port: daemonPort,
|
||||
platform: process.platform,
|
||||
})
|
||||
: undefined;
|
||||
const { portStatus, portCliStatus } = await inspectDaemonPortStatuses({
|
||||
daemonPort,
|
||||
cliPort,
|
||||
@@ -731,7 +746,7 @@ export async function gatherDaemonStatus(
|
||||
// diagnostics instead.
|
||||
// Best-effort: unreadable install records omit this advisory report.
|
||||
let pluginVersionDrift: PluginVersionDriftReport | undefined;
|
||||
if (daemonCfg.gateway?.mode !== "remote" && !probeUrlOverride) {
|
||||
if (shouldInspectLocalGateway) {
|
||||
try {
|
||||
const installRecords = await loadInstalledPluginIndexInstallRecords({
|
||||
env: mergedDaemonEnv as NodeJS.ProcessEnv,
|
||||
@@ -767,6 +782,7 @@ export async function gatherDaemonStatus(
|
||||
},
|
||||
gateway: {
|
||||
...gateway,
|
||||
...(windowsFirewall?.applies ? { windowsFirewall } : {}),
|
||||
...(opts.probe
|
||||
? {
|
||||
version: gatewayVersion,
|
||||
|
||||
@@ -183,6 +183,40 @@ describe("printDaemonStatus", () => {
|
||||
expectMockLineContains(runtime.log, "protocol mismatch after rollback");
|
||||
});
|
||||
|
||||
it("prints Windows firewall diagnostics in gateway status output", () => {
|
||||
printDaemonStatus(
|
||||
{
|
||||
service: {
|
||||
label: "LaunchAgent",
|
||||
loaded: true,
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
runtime: { status: "running", pid: 8000 },
|
||||
},
|
||||
gateway: {
|
||||
bindMode: "lan",
|
||||
bindHost: "0.0.0.0",
|
||||
port: 18789,
|
||||
portSource: "env/config",
|
||||
probeUrl: "ws://127.0.0.1:18789",
|
||||
windowsFirewall: {
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_local_rules_ignored",
|
||||
message:
|
||||
"Windows Firewall may ignore local Gateway allow rules for this network profile.",
|
||||
details: ["Windows reports LocalFirewallRules as N/A (GPO-store only)."],
|
||||
},
|
||||
},
|
||||
extraServices: [],
|
||||
},
|
||||
{ json: false, deep: true },
|
||||
);
|
||||
|
||||
expectMockLineContains(runtime.error, "Windows firewall: Windows Firewall may ignore");
|
||||
expectMockLineContains(runtime.error, "GPO-store only");
|
||||
});
|
||||
|
||||
it("uses service command env for WSL systemd unavailable hints", () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
|
||||
@@ -223,6 +223,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d
|
||||
if (status.gateway.probeNote) {
|
||||
defaultRuntime.log(`${label("Probe note:")} ${infoText(status.gateway.probeNote)}`);
|
||||
}
|
||||
if (status.gateway.windowsFirewall?.severity === "warning") {
|
||||
defaultRuntime.error(warnText(`Windows firewall: ${status.gateway.windowsFirewall.message}`));
|
||||
for (const detail of status.gateway.windowsFirewall.details) {
|
||||
defaultRuntime.error(warnText(` ${detail}`));
|
||||
}
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => {
|
||||
resolveAdvertisedControlUiLinks: vi.fn(),
|
||||
resolveControlUiLinks: vi.fn(),
|
||||
resolveLocalControlUiProbeLinks: vi.fn(),
|
||||
inspectWindowsGatewayFirewall: vi.fn(),
|
||||
summarizeExistingConfig: vi.fn(),
|
||||
promptAuthConfig: vi.fn(),
|
||||
promptGatewayConfig: vi.fn(),
|
||||
@@ -91,6 +92,16 @@ vi.mock("../infra/control-ui-assets.js", () => ({
|
||||
ensureControlUiAssetsBuilt: mocks.ensureControlUiAssetsBuilt,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({
|
||||
inspectWindowsGatewayFirewall: mocks.inspectWindowsGatewayFirewall,
|
||||
formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) =>
|
||||
params.bind === "lan"
|
||||
? [
|
||||
"Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.",
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
|
||||
vi.mock("../wizard/clack-prompter.js", () => ({
|
||||
createClackPrompter: mocks.createClackPrompter,
|
||||
}));
|
||||
@@ -232,6 +243,13 @@ function setupBaseWizardState(config: OpenClawConfig = {}) {
|
||||
httpUrl: "http://127.0.0.1:18789/",
|
||||
wsUrl: "ws://127.0.0.1:18789",
|
||||
});
|
||||
mocks.inspectWindowsGatewayFirewall.mockResolvedValue({
|
||||
applies: false,
|
||||
severity: "info",
|
||||
code: "windows_firewall_not_applicable",
|
||||
message: "Windows LAN firewall diagnostics do not apply.",
|
||||
details: [],
|
||||
});
|
||||
mocks.summarizeExistingConfig.mockReturnValue("");
|
||||
mocks.createClackPrompter.mockReturnValue({
|
||||
intro: vi.fn(async () => {}),
|
||||
@@ -409,6 +427,24 @@ describe("runConfigureWizard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows static Windows Firewall guidance for LAN Gateway links without inspection", async () => {
|
||||
setupBaseWizardState({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "lan",
|
||||
auth: { token: "token" },
|
||||
},
|
||||
});
|
||||
|
||||
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
|
||||
|
||||
expect(mocks.inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Windows firewall: if another device cannot connect to the LAN URL"),
|
||||
"Control UI",
|
||||
);
|
||||
});
|
||||
|
||||
it("exits with code 1 when configure wizard is cancelled", async () => {
|
||||
const runtime = createRuntime();
|
||||
setupBaseWizardState();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { logConfigUpdated } from "../config/logging.js";
|
||||
import { ConfigMutationConflictError } from "../config/mutate.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { formatWindowsGatewayFirewallGuidance } from "../infra/windows-gateway-firewall-diagnostics.js";
|
||||
import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -884,12 +885,14 @@ export async function runConfigureWizard(
|
||||
const gatewayStatusLine = gatewayProbe.ok
|
||||
? "Gateway: reachable"
|
||||
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||
const windowsFirewallLines = formatWindowsGatewayFirewallGuidance({ bind });
|
||||
|
||||
note(
|
||||
[
|
||||
`Web UI: ${displayLinks.httpUrl}`,
|
||||
`Gateway WS: ${displayLinks.wsUrl}`,
|
||||
gatewayStatusLine,
|
||||
...windowsFirewallLines,
|
||||
"Docs: https://docs.openclaw.ai/web/control-ui",
|
||||
].join("\n"),
|
||||
"Control UI",
|
||||
|
||||
@@ -47,6 +47,13 @@ const mocks = vi.hoisted(() => {
|
||||
fingerprintSha256: "sha256:local-fingerprint",
|
||||
}),
|
||||
),
|
||||
inspectWindowsGatewayFirewall: vi.fn<() => Promise<unknown>>(async () => ({
|
||||
applies: false,
|
||||
severity: "info",
|
||||
code: "windows_firewall_not_applicable",
|
||||
message: "Windows LAN firewall diagnostics do not apply.",
|
||||
details: [],
|
||||
})),
|
||||
probeGateway: vi.fn(async (opts: { url: string }): Promise<GatewayProbeResult> => {
|
||||
const { url } = opts;
|
||||
if (url.includes("127.0.0.1")) {
|
||||
@@ -153,6 +160,7 @@ const {
|
||||
resolveSshConfig,
|
||||
startSshPortForward,
|
||||
loadGatewayTlsRuntime,
|
||||
inspectWindowsGatewayFirewall,
|
||||
probeGateway,
|
||||
} = mocks;
|
||||
|
||||
@@ -215,6 +223,10 @@ vi.mock("../infra/tls/gateway.js", () => ({
|
||||
loadGatewayTlsRuntime: mocks.loadGatewayTlsRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({
|
||||
inspectWindowsGatewayFirewall: mocks.inspectWindowsGatewayFirewall,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/probe.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../gateway/probe.js")>()),
|
||||
probeGateway: mocks.probeGateway,
|
||||
@@ -301,6 +313,7 @@ async function runGatewayStatus(
|
||||
timeout: string;
|
||||
json?: boolean;
|
||||
port?: unknown;
|
||||
url?: string;
|
||||
ssh?: string;
|
||||
sshAuto?: boolean;
|
||||
sshIdentity?: string;
|
||||
@@ -376,6 +389,74 @@ describe("gateway-status command", () => {
|
||||
requireRecord(firstTarget.summary, "first target summary");
|
||||
});
|
||||
|
||||
it("does not run Windows LAN firewall diagnostics during fast gateway status", async () => {
|
||||
readBestEffortConfig.mockResolvedValueOnce({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "lan",
|
||||
auth: { token: "ltok" },
|
||||
},
|
||||
} as never);
|
||||
const { runtime, runtimeLogs } = createRuntimeCapture();
|
||||
|
||||
await runGatewayStatus(runtime, { timeout: "1000", json: true });
|
||||
|
||||
expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
|
||||
warnings: Array<{ code?: string }>;
|
||||
};
|
||||
expect(parsed.warnings.some((warning) => warning.code?.startsWith("windows_firewall_"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips local Windows firewall diagnostics for remote Gateway mode", async () => {
|
||||
readBestEffortConfig.mockResolvedValueOnce({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
bind: "lan",
|
||||
remote: { url: "wss://remote.example:18789", token: "rtok" },
|
||||
auth: { token: "ltok" },
|
||||
},
|
||||
} as never);
|
||||
const { runtime, runtimeLogs } = createRuntimeCapture();
|
||||
|
||||
await runGatewayStatus(runtime, { timeout: "1000", json: true });
|
||||
|
||||
expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
|
||||
warnings: Array<{ code?: string }>;
|
||||
};
|
||||
expect(parsed.warnings.some((warning) => warning.code?.startsWith("windows_firewall_"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips local Windows firewall diagnostics for explicit Gateway URLs", async () => {
|
||||
readBestEffortConfig.mockResolvedValueOnce({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "lan",
|
||||
auth: { token: "ltok" },
|
||||
},
|
||||
} as never);
|
||||
const { runtime, runtimeLogs } = createRuntimeCapture();
|
||||
|
||||
await runGatewayStatus(runtime, {
|
||||
timeout: "1000",
|
||||
json: true,
|
||||
url: "wss://remote.example:18789",
|
||||
});
|
||||
|
||||
expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
|
||||
warnings: Array<{ code?: string }>;
|
||||
};
|
||||
expect(parsed.warnings.some((warning) => warning.code?.startsWith("windows_firewall_"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces degraded model-pricing health as a warning", async () => {
|
||||
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||
const defaultProbeGateway = probeGateway.getMockImplementation();
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import type { GatewayStatusProbedTarget } from "./probe-run.js";
|
||||
|
||||
/** Warning emitted when gateway status finds degraded or surprising probe state. */
|
||||
type GatewayStatusWarning = {
|
||||
export type GatewayStatusWarning = {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: string[];
|
||||
targetIds?: string[];
|
||||
};
|
||||
|
||||
@@ -260,6 +261,9 @@ export function writeGatewayStatusText(params: {
|
||||
params.runtime.log(colorize(params.rich, theme.warn, "Warning:"));
|
||||
for (const warning of params.warnings) {
|
||||
params.runtime.log(`- ${warning.message}`);
|
||||
for (const detail of warning.details ?? []) {
|
||||
params.runtime.log(` ${detail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
// Windows Gateway firewall diagnostics classify LAN reachability risks.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS,
|
||||
QUICK_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS,
|
||||
inspectWindowsGatewayFirewall,
|
||||
parseWindowsGatewayFirewallState,
|
||||
classifyWindowsGatewayFirewallState,
|
||||
type WindowsGatewayFirewallCommandRunner,
|
||||
} from "./windows-gateway-firewall-diagnostics.js";
|
||||
import { getWindowsPowerShellExePath, getWindowsSystem32ExePath } from "./windows-install-roots.js";
|
||||
|
||||
function stateJson(params?: {
|
||||
networkCategory?: string;
|
||||
defaultInboundAction?: string;
|
||||
allowInboundRules?: string;
|
||||
activeAllowLocalRules?: string;
|
||||
localAllowRules?: string;
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
ConnectionProfiles: [
|
||||
{
|
||||
InterfaceAlias: "Ethernet",
|
||||
NetworkCategory: params?.networkCategory ?? "Public",
|
||||
},
|
||||
],
|
||||
ActiveFirewallProfiles: [
|
||||
{
|
||||
Name: "Public",
|
||||
Enabled: "True",
|
||||
DefaultInboundAction: params?.defaultInboundAction ?? "Block",
|
||||
AllowInboundRules: params?.allowInboundRules ?? "True",
|
||||
AllowLocalFirewallRules: params?.activeAllowLocalRules ?? "True",
|
||||
},
|
||||
],
|
||||
LocalFirewallProfiles: [
|
||||
{
|
||||
Name: "Public",
|
||||
Enabled: "True",
|
||||
DefaultInboundAction: "NotConfigured",
|
||||
AllowInboundRules: "NotConfigured",
|
||||
AllowLocalFirewallRules: params?.localAllowRules ?? "NotConfigured",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function multiProfileStateJson() {
|
||||
return JSON.stringify({
|
||||
ConnectionProfiles: [
|
||||
{
|
||||
InterfaceAlias: "Ethernet",
|
||||
NetworkCategory: "Public",
|
||||
},
|
||||
{
|
||||
InterfaceAlias: "Wi-Fi",
|
||||
NetworkCategory: "Private",
|
||||
},
|
||||
],
|
||||
ActiveFirewallProfiles: [
|
||||
{
|
||||
Name: "Public",
|
||||
Enabled: "True",
|
||||
DefaultInboundAction: "Block",
|
||||
AllowInboundRules: "True",
|
||||
AllowLocalFirewallRules: "False",
|
||||
},
|
||||
{
|
||||
Name: "Private",
|
||||
Enabled: "True",
|
||||
DefaultInboundAction: "Block",
|
||||
AllowInboundRules: "True",
|
||||
AllowLocalFirewallRules: "True",
|
||||
},
|
||||
],
|
||||
LocalFirewallProfiles: [
|
||||
{
|
||||
Name: "Public",
|
||||
Enabled: "True",
|
||||
DefaultInboundAction: "NotConfigured",
|
||||
AllowInboundRules: "NotConfigured",
|
||||
AllowLocalFirewallRules: "NotConfigured",
|
||||
},
|
||||
{
|
||||
Name: "Private",
|
||||
Enabled: "True",
|
||||
DefaultInboundAction: "NotConfigured",
|
||||
AllowInboundRules: "NotConfigured",
|
||||
AllowLocalFirewallRules: "NotConfigured",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function ruleJson(params?: {
|
||||
displayName?: string;
|
||||
profile?: string;
|
||||
policyStoreSource?: string;
|
||||
policyStoreSourceType?: string;
|
||||
program?: string;
|
||||
localAddress?: string;
|
||||
remoteAddress?: string;
|
||||
}) {
|
||||
return JSON.stringify([ruleRow(params)]);
|
||||
}
|
||||
|
||||
function quickPayloadJson(params?: {
|
||||
state?: string;
|
||||
activeRules?: Array<Record<string, unknown>>;
|
||||
localRules?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
State: JSON.parse(params?.state ?? stateJson({ localAllowRules: "True" })),
|
||||
ActiveRules: params?.activeRules ?? [],
|
||||
LocalRules: params?.localRules ?? [ruleRow()],
|
||||
});
|
||||
}
|
||||
|
||||
function rulesPayloadJson(params: { active?: unknown[]; local?: unknown[] }) {
|
||||
return JSON.stringify({
|
||||
ActiveRules: params.active ?? [],
|
||||
LocalRules: params.local ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function ruleRow(params?: {
|
||||
displayName?: string;
|
||||
profile?: string;
|
||||
policyStoreSource?: string;
|
||||
policyStoreSourceType?: string;
|
||||
program?: string;
|
||||
localAddress?: string;
|
||||
remoteAddress?: string;
|
||||
}) {
|
||||
return {
|
||||
DisplayName: params?.displayName ?? "OpenClaw Gateway",
|
||||
Profile: params?.profile ?? "Any",
|
||||
PolicyStoreSource: params?.policyStoreSource ?? "PersistentStore",
|
||||
PolicyStoreSourceType: params?.policyStoreSourceType ?? "Local",
|
||||
Program: params?.program ?? "Any",
|
||||
LocalAddress: params?.localAddress ?? "Any",
|
||||
RemoteAddress: params?.remoteAddress ?? "Any",
|
||||
};
|
||||
}
|
||||
|
||||
function classify(params: { stateJson: string; rulesJson: string; netshOutput?: string }) {
|
||||
const state = parseWindowsGatewayFirewallState(params);
|
||||
if (!state) {
|
||||
throw new Error("expected parsed firewall state");
|
||||
}
|
||||
return classifyWindowsGatewayFirewallState(state);
|
||||
}
|
||||
|
||||
describe("Windows Gateway firewall diagnostics", () => {
|
||||
it("does not run commands outside Windows LAN binding", async () => {
|
||||
const runner = vi.fn<WindowsGatewayFirewallCommandRunner>();
|
||||
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "loopback",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
applies: false,
|
||||
code: "windows_firewall_not_applicable",
|
||||
});
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
port: 18789,
|
||||
platform: "darwin",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
applies: false,
|
||||
code: "windows_firewall_not_applicable",
|
||||
});
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("detects managed Windows policy that ignores local Gateway allow rules", () => {
|
||||
const diagnostic = classify({
|
||||
stateJson: stateJson({
|
||||
activeAllowLocalRules: "False",
|
||||
localAllowRules: "NotConfigured",
|
||||
}),
|
||||
rulesJson: ruleJson(),
|
||||
netshOutput: "LocalFirewallRules N/A (GPO-store only)",
|
||||
});
|
||||
|
||||
expect(diagnostic).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_local_rules_ignored",
|
||||
});
|
||||
expect(diagnostic.details.join("\n")).toContain("GPO-store only");
|
||||
});
|
||||
|
||||
it("detects ignored local rules even when they are absent from ActiveStore", () => {
|
||||
const diagnostic = classify({
|
||||
stateJson: stateJson({
|
||||
activeAllowLocalRules: "False",
|
||||
localAllowRules: "NotConfigured",
|
||||
}),
|
||||
rulesJson: rulesPayloadJson({
|
||||
active: [],
|
||||
local: [ruleRow()],
|
||||
}),
|
||||
netshOutput: "LocalFirewallRules N/A (GPO-store only)",
|
||||
});
|
||||
|
||||
expect(diagnostic).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_local_rules_ignored",
|
||||
});
|
||||
expect(diagnostic.details.join("\n")).toContain("OpenClaw Gateway");
|
||||
});
|
||||
|
||||
it("requires every active profile to allow local firewall rules", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: multiProfileStateJson(),
|
||||
rulesJson: rulesPayloadJson({
|
||||
active: [],
|
||||
local: [ruleRow()],
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_local_rules_ignored",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat NotConfigured local-rule policy as blocked", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: stateJson({ localAllowRules: "NotConfigured" }),
|
||||
rulesJson: ruleJson(),
|
||||
netshOutput: "LocalFirewallRules N/A (GPO-store only)",
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "info",
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a local allow rule when local rules are enabled for the active profile", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: stateJson({ localAllowRules: "True" }),
|
||||
rulesJson: ruleJson(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "info",
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects allow rules when the active profile blocks inbound rules globally", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: stateJson({ allowInboundRules: "False", localAllowRules: "True" }),
|
||||
rulesJson: ruleJson(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_inbound_rules_disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat program-scoped rules as sufficient Gateway allow rules", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: stateJson({ localAllowRules: "True" }),
|
||||
rulesJson: ruleJson({ program: "C:\\Other\\server.exe" }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_program_scoped_rule_unverified",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat address-scoped rules as sufficient Gateway allow rules", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: stateJson({ localAllowRules: "True" }),
|
||||
rulesJson: ruleJson({ remoteAddress: "192.168.1.20" }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_address_scoped_rule_unverified",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a Gateway allow rule on the wrong Windows network profile", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: stateJson({ networkCategory: "Public" }),
|
||||
rulesJson: ruleJson({ profile: "Private" }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_rule_profile_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers managed rule profile mismatch over local-rule-disabled fallback", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: stateJson({
|
||||
networkCategory: "Public",
|
||||
activeAllowLocalRules: "False",
|
||||
}),
|
||||
rulesJson: rulesPayloadJson({
|
||||
active: [
|
||||
ruleRow({
|
||||
displayName: "Managed private allow",
|
||||
profile: "Private",
|
||||
policyStoreSource: "Intune",
|
||||
policyStoreSourceType: "MDM",
|
||||
}),
|
||||
],
|
||||
local: [],
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_rule_profile_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a blocking profile with no inbound allow rule for the Gateway port", () => {
|
||||
expect(
|
||||
classify({
|
||||
stateJson: stateJson(),
|
||||
rulesJson: "[]",
|
||||
}),
|
||||
).toMatchObject({
|
||||
applies: true,
|
||||
severity: "warning",
|
||||
code: "windows_firewall_no_allow_rule",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies empty successful rule output as no allow rule", async () => {
|
||||
const runner = vi.fn<WindowsGatewayFirewallCommandRunner>(async (argv) => {
|
||||
const command = argv.join(" ");
|
||||
if (command.includes("Get-NetConnectionProfile")) {
|
||||
return { code: 0, stdout: stateJson() };
|
||||
}
|
||||
if (command.includes("HNetCfg.FwPolicy2")) {
|
||||
return { code: 0, stdout: "" };
|
||||
}
|
||||
if (command.includes("advfirewall")) {
|
||||
return { code: 0, stdout: "" };
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
code: "windows_firewall_no_allow_rule",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when firewall rule output is truncated", async () => {
|
||||
const runner = vi.fn<WindowsGatewayFirewallCommandRunner>(async (argv) => {
|
||||
const command = argv.join(" ");
|
||||
if (command.includes("Get-NetConnectionProfile")) {
|
||||
return { code: 0, stdout: stateJson() };
|
||||
}
|
||||
if (command.includes("HNetCfg.FwPolicy2")) {
|
||||
return { code: 0, stdout: ruleJson(), stdoutTruncatedBytes: 1 };
|
||||
}
|
||||
if (command.includes("advfirewall")) {
|
||||
return { code: 0, stdout: "" };
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
code: "windows_firewall_inspection_failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports local-rule policy when the persistent detail probe is unavailable", async () => {
|
||||
const runner = vi.fn<WindowsGatewayFirewallCommandRunner>(async (argv, opts) => {
|
||||
const command = argv.join(" ");
|
||||
if (command.includes("Get-NetConnectionProfile")) {
|
||||
return { code: 0, stdout: stateJson({ activeAllowLocalRules: "False" }) };
|
||||
}
|
||||
if (command.includes("HNetCfg.FwPolicy2")) {
|
||||
return { code: 0, stdout: "" };
|
||||
}
|
||||
if (command.includes("PolicyStore ActiveStore")) {
|
||||
return { code: 0, stdout: "" };
|
||||
}
|
||||
if (command.includes("PolicyStore PersistentStore")) {
|
||||
expect(opts.timeoutMs).toBeGreaterThanOrEqual(10_000);
|
||||
return { code: null, stdout: "" };
|
||||
}
|
||||
if (command.includes("advfirewall")) {
|
||||
return { code: 0, stdout: "LocalFirewallRules N/A (GPO-store only)" };
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
code: "windows_firewall_local_rules_ignored",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves managed ActiveStore allow rules when local rules are disabled", async () => {
|
||||
const runner = vi.fn<WindowsGatewayFirewallCommandRunner>(async (argv) => {
|
||||
const command = argv.join(" ");
|
||||
if (command.includes("Get-NetConnectionProfile")) {
|
||||
return { code: 0, stdout: stateJson({ activeAllowLocalRules: "False" }) };
|
||||
}
|
||||
if (command.includes("HNetCfg.FwPolicy2")) {
|
||||
return { code: 0, stdout: ruleJson({ displayName: "Ignored local allow" }) };
|
||||
}
|
||||
if (command.includes("PolicyStore ActiveStore")) {
|
||||
expect(command).toContain("requestedPolicyStoreSourceTypes");
|
||||
expect(command).toContain("-ieq");
|
||||
expect(command).toContain("GroupPolicy");
|
||||
expect(command).toContain("MDM");
|
||||
return {
|
||||
code: 0,
|
||||
stdout: ruleJson({
|
||||
displayName: "MDM-managed Gateway allow",
|
||||
policyStoreSource: "Intune",
|
||||
policyStoreSourceType: "MDM",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command.includes("advfirewall")) {
|
||||
return { code: 0, stdout: "" };
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
severity: "info",
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
expect(
|
||||
runner.mock.calls.some(([argv]) => argv.join(" ").includes("PolicyStore PersistentStore")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps broad any-port rules from structured Windows rule output", () => {
|
||||
const diagnostic = classify({
|
||||
stateJson: stateJson({ localAllowRules: "True" }),
|
||||
rulesJson: rulesPayloadJson({ active: [ruleRow({ displayName: "Broad TCP allow" })] }),
|
||||
});
|
||||
|
||||
expect(diagnostic).toMatchObject({
|
||||
severity: "info",
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats COM wildcard addresses as address-agnostic", () => {
|
||||
const diagnostic = classify({
|
||||
stateJson: stateJson({ localAllowRules: "True" }),
|
||||
rulesJson: rulesPayloadJson({
|
||||
active: [ruleRow({ localAddress: "*", remoteAddress: "*" })],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(diagnostic).toMatchObject({
|
||||
severity: "info",
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat app-scoped any-port rules as sufficient Gateway allow rules", () => {
|
||||
const diagnostic = classify({
|
||||
stateJson: stateJson({ localAllowRules: "True" }),
|
||||
rulesJson: rulesPayloadJson({
|
||||
active: [ruleRow({ displayName: "Microsoft Teams", program: "Microsoft Teams" })],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(diagnostic).toMatchObject({
|
||||
severity: "warning",
|
||||
code: "windows_firewall_program_scoped_rule_unverified",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat service-scoped explicit port rules as sufficient Gateway allow rules", () => {
|
||||
const diagnostic = classify({
|
||||
stateJson: stateJson({ localAllowRules: "True" }),
|
||||
rulesJson: rulesPayloadJson({
|
||||
active: [ruleRow({ displayName: "Service rule", program: "SomeService" })],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(diagnostic).toMatchObject({
|
||||
severity: "warning",
|
||||
code: "windows_firewall_program_scoped_rule_unverified",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs a quick bounded Windows probe without netsh or follow-up commands", async () => {
|
||||
const runner = vi.fn<WindowsGatewayFirewallCommandRunner>(async (argv) => {
|
||||
const command = argv.join(" ");
|
||||
expect(command).toContain("Get-NetConnectionProfile");
|
||||
expect(command).toContain("HNetCfg.FwPolicy2");
|
||||
expect(command).toContain("Get-NetFirewallRule");
|
||||
expect(command).toContain("PolicyStore ActiveStore");
|
||||
expect(command).toContain("foreach ($entry in @($value))");
|
||||
expect(command).not.toContain("advfirewall");
|
||||
expect(command).not.toContain("PolicyStore PersistentStore");
|
||||
return { code: 0, stdout: quickPayloadJson() };
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
mode: "quick",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
expect(runner.mock.calls[0]?.[0][0]).toBe(getWindowsPowerShellExePath());
|
||||
expect(runner.mock.calls[0]?.[1]).toMatchObject({
|
||||
timeoutMs: QUICK_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves managed ActiveStore allow rules during quick inspection", async () => {
|
||||
const runner = vi.fn<WindowsGatewayFirewallCommandRunner>(async (argv) => {
|
||||
const command = argv.join(" ");
|
||||
expect(command).toContain("Get-NetFirewallRule");
|
||||
expect(command).toContain("GroupPolicy");
|
||||
expect(command).toContain("MDM");
|
||||
return {
|
||||
code: 0,
|
||||
stdout: quickPayloadJson({
|
||||
state: stateJson({ activeAllowLocalRules: "False" }),
|
||||
activeRules: [
|
||||
ruleRow({
|
||||
displayName: "MDM-managed Gateway allow",
|
||||
policyStoreSource: "Intune",
|
||||
policyStoreSourceType: "MDM",
|
||||
}),
|
||||
],
|
||||
localRules: [ruleRow({ displayName: "Ignored local allow" })],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
mode: "quick",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
severity: "info",
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs bounded read-only full Windows probes for LAN binding", async () => {
|
||||
const runner = vi.fn<WindowsGatewayFirewallCommandRunner>(async (argv) => {
|
||||
const command = argv.join(" ");
|
||||
if (command.includes("Get-NetConnectionProfile")) {
|
||||
return { code: 0, stdout: stateJson({ localAllowRules: "True" }) };
|
||||
}
|
||||
if (command.includes("HNetCfg.FwPolicy2")) {
|
||||
expect(command).toContain("$targetPort = 18789");
|
||||
expect(command).not.toContain("Grouping");
|
||||
expect(command).not.toContain("Description");
|
||||
expect(command).toContain("System.Collections.ArrayList");
|
||||
expect(command).toContain("$matchingRules.Add");
|
||||
expect(command).toContain("[string]$rule.LocalAddresses");
|
||||
expect(command).toContain("[string]$rule.RemoteAddresses");
|
||||
return { code: 0, stdout: ruleJson() };
|
||||
}
|
||||
if (command.includes("advfirewall")) {
|
||||
return { code: 0, stdout: "" };
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
timeoutMs: 1234,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
expect(runner).toHaveBeenCalledTimes(3);
|
||||
expect(runner.mock.calls.map(([argv]) => argv[0])).toEqual(
|
||||
expect.arrayContaining([
|
||||
getWindowsPowerShellExePath(),
|
||||
getWindowsSystem32ExePath("netsh.exe"),
|
||||
]),
|
||||
);
|
||||
for (const [, opts] of runner.mock.calls) {
|
||||
expect(opts).toMatchObject({ timeoutMs: 1234 });
|
||||
}
|
||||
|
||||
runner.mockClear();
|
||||
await expect(
|
||||
inspectWindowsGatewayFirewall({
|
||||
bind: "lan",
|
||||
port: 18789,
|
||||
platform: "win32",
|
||||
runCommandWithTimeout: runner,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
code: "windows_firewall_rule_present",
|
||||
});
|
||||
expect(runner).toHaveBeenCalledTimes(3);
|
||||
for (const [, opts] of runner.mock.calls) {
|
||||
expect(opts).toMatchObject({ timeoutMs: DEFAULT_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS });
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,6 +86,15 @@ const startGatewayServer = vi.hoisted(() =>
|
||||
close: vi.fn(async () => {}),
|
||||
})),
|
||||
);
|
||||
const inspectWindowsGatewayFirewall = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<unknown>>(async () => ({
|
||||
applies: false,
|
||||
severity: "info",
|
||||
code: "windows_firewall_not_applicable",
|
||||
message: "Windows LAN firewall diagnostics do not apply.",
|
||||
details: [],
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../commands/onboard-helpers.js", () => ({
|
||||
detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })),
|
||||
@@ -101,6 +110,16 @@ vi.mock("../commands/onboard-helpers.js", () => ({
|
||||
waitForGatewayReachable,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({
|
||||
inspectWindowsGatewayFirewall,
|
||||
formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) =>
|
||||
params.bind === "lan"
|
||||
? [
|
||||
"Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.",
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
|
||||
vi.mock("../commands/daemon-install-helpers.js", () => ({
|
||||
buildGatewayInstallPlan,
|
||||
gatewayInstallErrorHint: vi.fn(() => "hint"),
|
||||
@@ -379,6 +398,14 @@ describe("finalizeSetupWizard", () => {
|
||||
isContainerEnvironment.mockReturnValue(false);
|
||||
startGatewayServer.mockReset();
|
||||
startGatewayServer.mockResolvedValue({ close: vi.fn(async () => {}) });
|
||||
inspectWindowsGatewayFirewall.mockReset();
|
||||
inspectWindowsGatewayFirewall.mockResolvedValue({
|
||||
applies: false,
|
||||
severity: "info",
|
||||
code: "windows_firewall_not_applicable",
|
||||
message: "Windows LAN firewall diagnostics do not apply.",
|
||||
details: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves gateway password SecretRef for probe but omits auth from TUI hatch", async () => {
|
||||
@@ -506,6 +533,38 @@ describe("finalizeSetupWizard", () => {
|
||||
expectNoteContains(prompter, "ws://10.211.55.3:18789", "Control UI");
|
||||
});
|
||||
|
||||
it("shows static Windows Firewall guidance for LAN Control UI links without inspection", async () => {
|
||||
const prompter = createLaterPrompter();
|
||||
const args = createAdvancedFinalizeArgs({
|
||||
nextConfig: {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
});
|
||||
|
||||
await finalizeSetupWizard({
|
||||
...args,
|
||||
opts: {
|
||||
...args.opts,
|
||||
skipHealth: false,
|
||||
skipUi: false,
|
||||
},
|
||||
settings: {
|
||||
...args.settings,
|
||||
bind: "lan",
|
||||
},
|
||||
});
|
||||
|
||||
expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
|
||||
expectNoteContains(
|
||||
prompter,
|
||||
"Windows firewall: if another device cannot connect to the LAN URL",
|
||||
"Control UI",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds the bootstrap hatch TUI run timeout", async () => {
|
||||
vi.spyOn(fs, "access").mockResolvedValueOnce(undefined);
|
||||
const select = vi.fn(async (params: { message: string }) => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||
import { isContainerEnvironment } from "../infra/container-environment.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { formatWindowsGatewayFirewallGuidance } from "../infra/windows-gateway-firewall-diagnostics.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { launchTuiCli } from "../tui/tui-launch.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
@@ -556,6 +557,9 @@ export async function finalizeSetupWizard(
|
||||
: t("wizard.finalize.gatewayNotDetectedStatus", {
|
||||
detail: gatewayProbe.detail ? ` (${gatewayProbe.detail})` : "",
|
||||
});
|
||||
const windowsFirewallLines = formatWindowsGatewayFirewallGuidance({
|
||||
bind: settings.bind,
|
||||
});
|
||||
const bootstrapPath = path.join(
|
||||
resolveUserPath(options.workspaceDir),
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
@@ -574,6 +578,7 @@ export async function finalizeSetupWizard(
|
||||
: undefined,
|
||||
t("wizard.finalize.gatewayWsUrl", { url: displayLinks.wsUrl }),
|
||||
gatewayStatusLine,
|
||||
...windowsFirewallLines,
|
||||
t("wizard.finalize.controlUiDocs"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
Reference in New Issue
Block a user