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:
Josh Avant
2026-07-01 16:24:16 -05:00
committed by GitHub
parent e79865569c
commit eb417fa206
13 changed files with 2007 additions and 2 deletions
+4
View File
@@ -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`
+46
View File
@@ -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();
+17 -1
View File
@@ -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,
+34
View File
@@ -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" });
+6
View File
@@ -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
View File
@@ -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();
+3
View File
@@ -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",
+81
View File
@@ -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();
+5 -1
View File
@@ -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
+59
View File
@@ -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 }) => {
+5
View File
@@ -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)