diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 0fbefff05562..8cdeaefdc545 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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`. + +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. + + Auth is supplied during the WebSocket handshake via: - `connect.params.auth.token` diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 69d748f2910a..4e06470446cf 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -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>(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(); diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index b838175f411b..266379d7da30 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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, diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index aa88dd11793a..cad4b41efad2 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -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" }); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 863ded1b1098..4e6b63add03f 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -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(); } diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index dacfce271662..a1cd57f4840a 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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(); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 80e2593f3048..ea26e5402a22 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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", diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index a0c524abaf57..2a2187324058 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -47,6 +47,13 @@ const mocks = vi.hoisted(() => { fingerprintSha256: "sha256:local-fingerprint", }), ), + inspectWindowsGatewayFirewall: vi.fn<() => Promise>(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 => { 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()), 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(); diff --git a/src/commands/gateway-status/output.ts b/src/commands/gateway-status/output.ts index 5ef928274172..cac458025a29 100644 --- a/src/commands/gateway-status/output.ts +++ b/src/commands/gateway-status/output.ts @@ -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}`); + } } } diff --git a/src/infra/windows-gateway-firewall-diagnostics.test.ts b/src/infra/windows-gateway-firewall-diagnostics.test.ts new file mode 100644 index 000000000000..ea06c439f0e5 --- /dev/null +++ b/src/infra/windows-gateway-firewall-diagnostics.test.ts @@ -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>; + localRules?: Array>; +}) { + 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(); + + 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(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(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(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(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(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(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(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 }); + } + }); +}); diff --git a/src/infra/windows-gateway-firewall-diagnostics.ts b/src/infra/windows-gateway-firewall-diagnostics.ts new file mode 100644 index 000000000000..da9f078ed840 --- /dev/null +++ b/src/infra/windows-gateway-firewall-diagnostics.ts @@ -0,0 +1,1040 @@ +// Read-only diagnostics for Windows LAN Gateway reachability. +import { runCommandWithTimeout as defaultRunCommandWithTimeout } from "../process/exec.js"; +import { getWindowsPowerShellExePath, getWindowsSystem32ExePath } from "./windows-install-roots.js"; + +export const DEFAULT_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS = 5_000; +export const QUICK_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS = 5_000; +const DEFAULT_OUTPUT_BYTES = 2 * 1024 * 1024; +const WINDOWS_MANAGED_FIREWALL_POLICY_SOURCE_TYPES = [ + "GroupPolicy", + "Dynamic", + "Generated", + "Hardcoded", + "MDM", + "HostFirewallGroupPolicy", + "HostFirewallDynamic", + "HostFirewallMDM", +]; + +const WINDOWS_FIREWALL_STATE_COMMAND = [ + "$ErrorActionPreference = 'Stop'", + "$connections = Get-NetConnectionProfile | Select-Object InterfaceAlias, @{Name='NetworkCategory';Expression={$_.NetworkCategory.ToString()}}", + "$activeProfiles = Get-NetFirewallProfile -PolicyStore ActiveStore | Select-Object Name, @{Name='Enabled';Expression={$_.Enabled.ToString()}}, @{Name='DefaultInboundAction';Expression={$_.DefaultInboundAction.ToString()}}, @{Name='AllowInboundRules';Expression={$_.AllowInboundRules.ToString()}}, @{Name='AllowLocalFirewallRules';Expression={$_.AllowLocalFirewallRules.ToString()}}", + "$localProfiles = Get-NetFirewallProfile -PolicyStore localhost | Select-Object Name, @{Name='Enabled';Expression={$_.Enabled.ToString()}}, @{Name='DefaultInboundAction';Expression={$_.DefaultInboundAction.ToString()}}, @{Name='AllowInboundRules';Expression={$_.AllowInboundRules.ToString()}}, @{Name='AllowLocalFirewallRules';Expression={$_.AllowLocalFirewallRules.ToString()}}", + "[pscustomobject]@{ConnectionProfiles = $connections; ActiveFirewallProfiles = $activeProfiles; LocalFirewallProfiles = $localProfiles} | ConvertTo-Json -Depth 4 -Compress", +].join("\n"); + +function buildWindowsNetSecurityFirewallRulesCommand( + port: number, + policyStore: "ActiveStore" | "PersistentStore", + policyStoreSourceTypes?: readonly string[], +): string { + const sourceTypeNames = policyStoreSourceTypes?.map((name) => `'${name}'`).join(", "); + const sourceTypeSetup = sourceTypeNames + ? ` +$policyStoreSourceType = (Get-Command Get-NetFirewallRule).Parameters['PolicyStoreSourceType'].ParameterType.GetElementType() +$requestedPolicyStoreSourceTypes = @(${sourceTypeNames}) +$supportedPolicyStoreSourceTypes = [enum]::GetNames($policyStoreSourceType) +$policyStoreSourceTypes = @( + foreach ($requestedPolicyStoreSourceType in $requestedPolicyStoreSourceTypes) { + $supportedPolicyStoreSourceTypes | Where-Object { $_ -ieq $requestedPolicyStoreSourceType } | Select-Object -First 1 + } +) +` + : ""; + const ruleQuery = sourceTypeNames + ? ` +$rules = if ($policyStoreSourceTypes.Count -gt 0) { + @(Get-NetFirewallRule -Direction Inbound -Enabled True -Action Allow -PolicyStore ${policyStore} -PolicyStoreSourceType $policyStoreSourceTypes -ErrorAction SilentlyContinue) +} else { + @() +} +` + : ` +$rules = @(Get-NetFirewallRule -Direction Inbound -Enabled True -Action Allow -PolicyStore ${policyStore}) +`; + return ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$targetPort = ${port} +${sourceTypeSetup} +function Test-OpenClawPortMatch($value) { + foreach ($entry in @($value)) { + $text = ([string]$entry).Trim() + if ($text -eq 'Any') { return $true } + foreach ($part in $text -split ',') { + $range = $part.Trim() + if ($range -eq ([string]$targetPort)) { return $true } + if ($range -match '^(\\d+)-(\\d+)$') { + $start = [int]$Matches[1] + $end = [int]$Matches[2] + if ($start -le $targetPort -and $targetPort -le $end) { return $true } + } + } + } + return $false +} +${ruleQuery} +$matchingRules = New-Object System.Collections.ArrayList +foreach ($rule in $rules) { + foreach ($portFilter in @($rule | Get-NetFirewallPortFilter)) { + $protocol = $portFilter.Protocol.ToString() + if (($protocol -eq 'Any' -or $protocol -eq 'TCP') -and (Test-OpenClawPortMatch $portFilter.LocalPort)) { + $appFilter = $rule | Get-NetFirewallApplicationFilter + $addressFilter = $rule | Get-NetFirewallAddressFilter + [void]$matchingRules.Add([pscustomobject]@{ + DisplayName = [string]$rule.DisplayName + Name = [string]$rule.Name + Profile = [string]$rule.Profile + PolicyStoreSource = [string]$rule.PolicyStoreSource + PolicyStoreSourceType = $rule.PolicyStoreSourceType.ToString() + Program = [string]$appFilter.Program + LocalAddress = [string]$addressFilter.LocalAddress + RemoteAddress = [string]$addressFilter.RemoteAddress + }) + } + } +} +$matchingRules | ConvertTo-Json -Depth 4 -Compress +`.trim(); +} + +function buildWindowsPersistentFirewallRulesCommand(port: number): string { + return buildWindowsNetSecurityFirewallRulesCommand(port, "PersistentStore"); +} + +function buildWindowsManagedActiveFirewallRulesCommand(port: number): string { + return buildWindowsNetSecurityFirewallRulesCommand( + port, + "ActiveStore", + WINDOWS_MANAGED_FIREWALL_POLICY_SOURCE_TYPES, + ); +} + +function buildWindowsFirewallRulesCommand(port: number): string { + return ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$targetPort = ${port} +function Test-OpenClawPortMatch($value) { + $text = ([string]$value).Trim() + if ($text -eq '' -or $text -eq '*') { return $true } + foreach ($part in $text -split ',') { + $range = $part.Trim() + if ($range -eq ([string]$targetPort)) { return $true } + if ($range -match '^(\\d+)-(\\d+)$') { + $start = [int]$Matches[1] + $end = [int]$Matches[2] + if ($start -le $targetPort -and $targetPort -le $end) { return $true } + } + } + return $false +} +function Resolve-OpenClawProgramScope($rule) { + $program = ([string]$rule.ApplicationName).Trim() + if ($program) { return $program } + foreach ($field in @('serviceName', 'LocalAppPackageId', 'LocalUserOwner')) { + $value = ([string]$rule.$field).Trim() + if ($value) { return $value } + } + $ports = ([string]$rule.LocalPorts).Trim() + if ($ports -ne '' -and $ports -ne '*') { return 'Any' } + return 'Any' +} +$policy = New-Object -ComObject HNetCfg.FwPolicy2 +$matchingRules = New-Object System.Collections.ArrayList +foreach ($rule in $policy.Rules) { + if (-not $rule.Enabled -or $rule.Direction -ne 1 -or $rule.Action -ne 1) { continue } + $protocol = if ($rule.Protocol -eq 6) { 'TCP' } elseif ($rule.Protocol -eq 256) { 'Any' } else { [string]$rule.Protocol } + if (($protocol -ne 'TCP' -and $protocol -ne 'Any') -or -not (Test-OpenClawPortMatch $rule.LocalPorts)) { continue } + [void]$matchingRules.Add([pscustomobject]@{ + DisplayName = [string]$rule.Name + Name = [string]$rule.Name + Profile = [string]$rule.Profiles + PolicyStoreSource = 'PersistentStore' + PolicyStoreSourceType = 'Local' + Program = (Resolve-OpenClawProgramScope $rule) + LocalAddress = [string]$rule.LocalAddresses + RemoteAddress = [string]$rule.RemoteAddresses + }) +} +$matchingRules | ConvertTo-Json -Depth 4 -Compress +`.trim(); +} + +function buildWindowsQuickFirewallCommand(port: number): string { + const sourceTypeNames = WINDOWS_MANAGED_FIREWALL_POLICY_SOURCE_TYPES.map( + (name) => `'${name}'`, + ).join(", "); + return ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$targetPort = ${port} +function Test-OpenClawPortMatch($value) { + foreach ($entry in @($value)) { + $text = ([string]$entry).Trim() + if ($text -eq '' -or $text -eq '*' -or $text -eq 'Any') { return $true } + foreach ($part in $text -split ',') { + $range = $part.Trim() + if ($range -eq ([string]$targetPort)) { return $true } + if ($range -match '^(\\d+)-(\\d+)$') { + $start = [int]$Matches[1] + $end = [int]$Matches[2] + if ($start -le $targetPort -and $targetPort -le $end) { return $true } + } + } + } + return $false +} +function Resolve-OpenClawProgramScope($rule) { + $program = ([string]$rule.ApplicationName).Trim() + if ($program) { return $program } + foreach ($field in @('serviceName', 'LocalAppPackageId', 'LocalUserOwner')) { + $value = ([string]$rule.$field).Trim() + if ($value) { return $value } + } + $ports = ([string]$rule.LocalPorts).Trim() + if ($ports -ne '' -and $ports -ne '*') { return 'Any' } + return 'Any' +} +function Get-OpenClawManagedRules { + try { + $getRule = Get-Command Get-NetFirewallRule -ErrorAction Stop + $sourceTypeParameter = $getRule.Parameters['PolicyStoreSourceType'] + if ($null -eq $sourceTypeParameter) { return @() } + $sourceType = $sourceTypeParameter.ParameterType + if ($sourceType.IsArray) { $sourceType = $sourceType.GetElementType() } + $requestedPolicyStoreSourceTypes = @(${sourceTypeNames}) + $supportedPolicyStoreSourceTypes = [enum]::GetNames($sourceType) + $policyStoreSourceTypes = @( + foreach ($requestedPolicyStoreSourceType in $requestedPolicyStoreSourceTypes) { + $supportedPolicyStoreSourceTypes | Where-Object { $_ -ieq $requestedPolicyStoreSourceType } | Select-Object -First 1 + } + ) + if ($policyStoreSourceTypes.Count -eq 0) { return @() } + $rules = @(Get-NetFirewallRule -Direction Inbound -Enabled True -Action Allow -PolicyStore ActiveStore -PolicyStoreSourceType $policyStoreSourceTypes -ErrorAction SilentlyContinue) + $matchingRules = New-Object System.Collections.ArrayList + foreach ($rule in $rules) { + foreach ($portFilter in @($rule | Get-NetFirewallPortFilter)) { + $protocol = $portFilter.Protocol.ToString() + if (($protocol -eq 'Any' -or $protocol -eq 'TCP') -and (Test-OpenClawPortMatch $portFilter.LocalPort)) { + $appFilter = $rule | Get-NetFirewallApplicationFilter + $addressFilter = $rule | Get-NetFirewallAddressFilter + [void]$matchingRules.Add([pscustomobject]@{ + DisplayName = [string]$rule.DisplayName + Name = [string]$rule.Name + Profile = [string]$rule.Profile + PolicyStoreSource = [string]$rule.PolicyStoreSource + PolicyStoreSourceType = $rule.PolicyStoreSourceType.ToString() + Program = [string]$appFilter.Program + LocalAddress = [string]$addressFilter.LocalAddress + RemoteAddress = [string]$addressFilter.RemoteAddress + }) + } + } + } + return $matchingRules + } catch { + return @() + } +} +$connections = Get-NetConnectionProfile | Select-Object InterfaceAlias, @{Name='NetworkCategory';Expression={$_.NetworkCategory.ToString()}} +$activeProfiles = Get-NetFirewallProfile -PolicyStore ActiveStore | Select-Object Name, @{Name='Enabled';Expression={$_.Enabled.ToString()}}, @{Name='DefaultInboundAction';Expression={$_.DefaultInboundAction.ToString()}}, @{Name='AllowInboundRules';Expression={$_.AllowInboundRules.ToString()}}, @{Name='AllowLocalFirewallRules';Expression={$_.AllowLocalFirewallRules.ToString()}} +$localProfiles = Get-NetFirewallProfile -PolicyStore localhost | Select-Object Name, @{Name='Enabled';Expression={$_.Enabled.ToString()}}, @{Name='DefaultInboundAction';Expression={$_.DefaultInboundAction.ToString()}}, @{Name='AllowInboundRules';Expression={$_.AllowInboundRules.ToString()}}, @{Name='AllowLocalFirewallRules';Expression={$_.AllowLocalFirewallRules.ToString()}} +$managedMatchingRules = @(Get-OpenClawManagedRules) +$policy = New-Object -ComObject HNetCfg.FwPolicy2 +$matchingRules = New-Object System.Collections.ArrayList +foreach ($rule in $policy.Rules) { + if (-not $rule.Enabled -or $rule.Direction -ne 1 -or $rule.Action -ne 1) { continue } + $protocol = if ($rule.Protocol -eq 6) { 'TCP' } elseif ($rule.Protocol -eq 256) { 'Any' } else { [string]$rule.Protocol } + if (($protocol -ne 'TCP' -and $protocol -ne 'Any') -or -not (Test-OpenClawPortMatch $rule.LocalPorts)) { continue } + [void]$matchingRules.Add([pscustomobject]@{ + DisplayName = [string]$rule.Name + Name = [string]$rule.Name + Profile = [string]$rule.Profiles + PolicyStoreSource = 'PersistentStore' + PolicyStoreSourceType = 'Local' + Program = (Resolve-OpenClawProgramScope $rule) + LocalAddress = [string]$rule.LocalAddresses + RemoteAddress = [string]$rule.RemoteAddresses + }) +} +[pscustomobject]@{ + State = [pscustomobject]@{ + ConnectionProfiles = $connections + ActiveFirewallProfiles = $activeProfiles + LocalFirewallProfiles = $localProfiles + } + ActiveRules = $managedMatchingRules + LocalRules = $matchingRules +} | ConvertTo-Json -Depth 5 -Compress +`.trim(); +} + +export type WindowsGatewayFirewallDiagnosticCode = + | "windows_firewall_not_applicable" + | "windows_firewall_unrestricted" + | "windows_firewall_rule_present" + | "windows_firewall_rule_profile_mismatch" + | "windows_firewall_program_scoped_rule_unverified" + | "windows_firewall_address_scoped_rule_unverified" + | "windows_firewall_inbound_rules_disabled" + | "windows_firewall_local_rules_ignored" + | "windows_firewall_no_allow_rule" + | "windows_firewall_inspection_failed"; + +export type WindowsGatewayFirewallDiagnostic = { + applies: boolean; + severity: "info" | "warning"; + code: WindowsGatewayFirewallDiagnosticCode; + message: string; + details: string[]; +}; + +export type WindowsGatewayFirewallCommandResult = { + code: number | null; + stdout: string; + stderr?: string; + stdoutTruncatedBytes?: number; + stderrTruncatedBytes?: number; +}; + +export type WindowsGatewayFirewallCommandRunner = ( + argv: string[], + opts: { timeoutMs: number; maxOutputBytes?: number }, +) => Promise; + +export type InspectWindowsGatewayFirewallParams = { + bind: string | undefined; + port: number; + mode?: "quick" | "full"; + platform?: NodeJS.Platform; + runCommandWithTimeout?: WindowsGatewayFirewallCommandRunner; + timeoutMs?: number; +}; + +type FirewallStatePayload = { + ConnectionProfiles?: unknown; + ActiveFirewallProfiles?: unknown; + LocalFirewallProfiles?: unknown; +}; + +type FirewallProfile = { + name: string; + enabled: string; + defaultInboundAction: string; + allowInboundRules: string; + allowLocalFirewallRules: string; +}; + +type FirewallRule = { + displayName: string; + profile: string; + policyStoreSource: string; + policyStoreSourceType: string; + program: string; + localAddress: string; + remoteAddress: string; +}; + +type ClassifiedFirewallState = { + activeProfileNames: string[]; + activeProfiles: FirewallProfile[]; + localProfiles: FirewallProfile[]; + matchingRules: FirewallRule[]; + localMatchingRules: FirewallRule[]; + netshOutput: string; +}; + +type QuickFirewallPayload = { + State?: unknown; + ActiveRules?: unknown; + LocalRules?: unknown; +}; + +function powershell(command: string): string[] { + return [ + getWindowsPowerShellExePath(), + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + command, + ]; +} + +async function runBestEffortCommand( + runCommandWithTimeout: WindowsGatewayFirewallCommandRunner, + argv: string[], + timeoutMs: number, +): Promise { + try { + const result = await runCommandWithTimeout(argv, { + timeoutMs, + maxOutputBytes: DEFAULT_OUTPUT_BYTES, + }); + if ((result.stdoutTruncatedBytes ?? 0) > 0 || (result.stderrTruncatedBytes ?? 0) > 0) { + return null; + } + return result.code === 0 ? result.stdout : null; + } catch { + return null; + } +} + +function parseJsonRows(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + return value && typeof value === "object" ? [value] : []; +} + +function parseJsonPayload(stdout: string): unknown { + const trimmed = stdout.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function stringField(row: Record, key: string): string { + const value = row[key]; + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value).trim(); + } + return ""; +} + +function normalizeProfileName(value: string): string { + const normalized = value.trim().toLowerCase(); + if (normalized === "domainauthenticated") { + return "domain"; + } + return normalized; +} + +function parseFirewallProfiles(value: unknown): FirewallProfile[] { + return parseJsonRows(value) + .filter((row): row is Record => Boolean(row) && typeof row === "object") + .map((row) => ({ + name: normalizeProfileName(stringField(row, "Name")), + enabled: stringField(row, "Enabled").toLowerCase(), + defaultInboundAction: stringField(row, "DefaultInboundAction").toLowerCase(), + allowInboundRules: stringField(row, "AllowInboundRules").toLowerCase(), + allowLocalFirewallRules: stringField(row, "AllowLocalFirewallRules").toLowerCase(), + })) + .filter((profile) => profile.name.length > 0); +} + +function parseConnectionProfileNames(value: unknown): string[] { + const names = parseJsonRows(value) + .filter((row): row is Record => Boolean(row) && typeof row === "object") + .map((row) => normalizeProfileName(stringField(row, "NetworkCategory"))) + .filter(Boolean); + return [...new Set(names)]; +} + +function parseFirewallRules(value: unknown): FirewallRule[] { + return parseJsonRows(value) + .filter((row): row is Record => Boolean(row) && typeof row === "object") + .map((row) => ({ + displayName: + stringField(row, "DisplayName") || + stringField(row, "displayName") || + stringField(row, "Name") || + "unnamed rule", + profile: (stringField(row, "Profile") || stringField(row, "profile")).toLowerCase(), + policyStoreSource: ( + stringField(row, "PolicyStoreSource") || stringField(row, "policyStoreSource") + ).toLowerCase(), + policyStoreSourceType: ( + stringField(row, "PolicyStoreSourceType") || stringField(row, "policyStoreSourceType") + ).toLowerCase(), + program: (stringField(row, "Program") || stringField(row, "program")).toLowerCase(), + localAddress: ( + stringField(row, "LocalAddress") || stringField(row, "localAddress") + ).toLowerCase(), + remoteAddress: ( + stringField(row, "RemoteAddress") || stringField(row, "remoteAddress") + ).toLowerCase(), + })); +} + +function isTruthyFirewallValue(value: string): boolean { + return value === "true" || value === "allow" || value === "1"; +} + +function isBlockingInbound(profile: FirewallProfile): boolean { + return profile.enabled !== "false" && profile.defaultInboundAction !== "allow"; +} + +function inboundRulesAreAllowed(profiles: FirewallProfile[]): boolean { + return profiles.every((profile) => profile.allowInboundRules !== "false"); +} + +function findProfileSettings( + profiles: FirewallProfile[], + activeProfileNames: string[], +): FirewallProfile[] { + if (activeProfileNames.length === 0) { + return profiles; + } + return profiles.filter((profile) => activeProfileNames.includes(profile.name)); +} + +function profileMaskMatches(value: number, activeProfileNames: string[]): boolean { + const masks: Record = { + domain: 1, + private: 2, + public: 4, + }; + return activeProfileNames.some((name) => (value & (masks[name] ?? 0)) !== 0); +} + +function ruleMatchesActiveProfile(rule: FirewallRule, activeProfileNames: string[]): boolean { + if (activeProfileNames.length === 0) { + return true; + } + const profile = rule.profile; + if (!profile || profile === "any" || profile === "all") { + return true; + } + const numeric = Number.parseInt(profile, 10); + if (Number.isFinite(numeric)) { + return profileMaskMatches(numeric, activeProfileNames); + } + return activeProfileNames.some((name) => profile.includes(name)); +} + +function isLocalRule(rule: FirewallRule): boolean { + return ( + !rule.policyStoreSourceType || + rule.policyStoreSourceType === "local" || + rule.policyStoreSourceType === "persistentstore" || + rule.policyStoreSource === "persistentstore" + ); +} + +function isProgramAgnosticRule(rule: FirewallRule): boolean { + return !rule.program || rule.program === "any"; +} + +function isAnyAddress(value: string): boolean { + return !value || value === "any" || value === "*"; +} + +function isAddressAgnosticRule(rule: FirewallRule): boolean { + return isAnyAddress(rule.localAddress) && isAnyAddress(rule.remoteAddress); +} + +function localRulesAreAllowed(params: { + activeProfileNames: string[]; + activeProfiles: FirewallProfile[]; + localProfiles: FirewallProfile[]; +}): boolean { + const activeProfiles = findProfileSettings(params.activeProfiles, params.activeProfileNames); + const explicitActiveProfiles = activeProfiles.filter( + (profile) => + profile.allowLocalFirewallRules && profile.allowLocalFirewallRules !== "notconfigured", + ); + if (explicitActiveProfiles.length > 0) { + return explicitActiveProfiles.every((profile) => + isTruthyFirewallValue(profile.allowLocalFirewallRules), + ); + } + + const localProfiles = findProfileSettings(params.localProfiles, params.activeProfileNames); + const explicitLocalProfiles = localProfiles.filter( + (profile) => + profile.allowLocalFirewallRules && profile.allowLocalFirewallRules !== "notconfigured", + ); + if (explicitLocalProfiles.length > 0) { + return explicitLocalProfiles.every((profile) => + isTruthyFirewallValue(profile.allowLocalFirewallRules), + ); + } + + return true; +} + +function formatProfiles(activeProfileNames: string[]): string { + return activeProfileNames.length > 0 ? activeProfileNames.join(", ") : "unknown"; +} + +function formatRuleNames(rules: FirewallRule[]): string { + return rules + .map((rule) => rule.displayName) + .filter(Boolean) + .join(", "); +} + +export function classifyWindowsGatewayFirewallState( + state: ClassifiedFirewallState, +): WindowsGatewayFirewallDiagnostic { + const activeProfiles = findProfileSettings(state.activeProfiles, state.activeProfileNames); + const blockingProfiles = activeProfiles.filter(isBlockingInbound); + const matchingActiveRules = state.matchingRules.filter((rule) => + ruleMatchesActiveProfile(rule, state.activeProfileNames), + ); + const programAgnosticMatchingRules = matchingActiveRules.filter( + (rule) => isProgramAgnosticRule(rule) && isAddressAgnosticRule(rule), + ); + const programScopedMatchingRules = matchingActiveRules.filter( + (rule) => !isProgramAgnosticRule(rule), + ); + const addressScopedMatchingRules = matchingActiveRules.filter( + (rule) => isProgramAgnosticRule(rule) && !isAddressAgnosticRule(rule), + ); + const localMatchingRules = state.localMatchingRules.filter((rule) => + ruleMatchesActiveProfile(rule, state.activeProfileNames), + ); + const programAgnosticLocalRules = localMatchingRules.filter( + (rule) => isProgramAgnosticRule(rule) && isAddressAgnosticRule(rule), + ); + const mismatchedRules = state.matchingRules.filter( + (rule) => !ruleMatchesActiveProfile(rule, state.activeProfileNames), + ); + const activeProfileText = formatProfiles(state.activeProfileNames); + + if (activeProfiles.length > 0 && blockingProfiles.length === 0) { + return { + applies: true, + severity: "info", + code: "windows_firewall_unrestricted", + message: + "Windows Firewall is not blocking unsolicited inbound traffic on the active profile.", + details: [`Active network profile: ${activeProfileText}.`], + }; + } + + if (programAgnosticMatchingRules.length > 0) { + if (!inboundRulesAreAllowed(activeProfiles)) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inbound_rules_disabled", + message: + "Windows Firewall is configured to block inbound connections even when allow rules exist.", + details: [ + `Active network profile: ${activeProfileText}.`, + `Matching allow rule(s): ${formatRuleNames(programAgnosticMatchingRules)}.`, + "Enable inbound rules for the active Windows Firewall profile, or use loopback, Tailscale, or an SSH tunnel instead of LAN binding.", + ], + }; + } + const localRules = programAgnosticMatchingRules.filter(isLocalRule); + const onlyLocalRules = localRules.length === programAgnosticMatchingRules.length; + if (onlyLocalRules && !localRulesAreAllowed(state)) { + const policyDetail = /gpo-store only/i.test(state.netshOutput) + ? "Windows reports LocalFirewallRules as N/A (GPO-store only)." + : "Local firewall rules are not explicitly enabled for the active profile."; + return { + applies: true, + severity: "warning", + code: "windows_firewall_local_rules_ignored", + message: "Windows Firewall may ignore local Gateway allow rules for this network profile.", + details: [ + `Active network profile: ${activeProfileText}.`, + `Matching local allow rule(s): ${formatRuleNames(programAgnosticMatchingRules)}.`, + policyDetail, + "Use a Group Policy/administrator-managed inbound TCP allow rule for the Gateway port, or switch to a network path such as loopback, Tailscale, or an SSH tunnel.", + ], + }; + } + return { + applies: true, + severity: "info", + code: "windows_firewall_rule_present", + message: + "Windows Firewall has an inbound TCP allow rule for the Gateway port on the active profile.", + details: [ + `Active network profile: ${activeProfileText}.`, + `Matching allow rule(s): ${formatRuleNames(programAgnosticMatchingRules)}.`, + "If another device still cannot connect, verify the advertised LAN URL from that device.", + ], + }; + } + + if (programScopedMatchingRules.length > 0) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_program_scoped_rule_unverified", + message: + "Windows Firewall has a matching port allow rule, but it is scoped to a specific program.", + details: [ + `Active network profile: ${activeProfileText}.`, + `Program-scoped allow rule(s): ${formatRuleNames(programScopedMatchingRules)}.`, + "Create an inbound TCP allow rule for the Gateway port that is not scoped to another executable, or verify the advertised LAN URL from another device.", + ], + }; + } + + if (addressScopedMatchingRules.length > 0) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_address_scoped_rule_unverified", + message: + "Windows Firewall has a matching port allow rule, but it is scoped to specific addresses.", + details: [ + `Active network profile: ${activeProfileText}.`, + `Address-scoped allow rule(s): ${formatRuleNames(addressScopedMatchingRules)}.`, + "Create an inbound TCP allow rule for the Gateway port that covers LAN clients, or verify the advertised LAN URL from another device.", + ], + }; + } + + if (programAgnosticLocalRules.length > 0 && !localRulesAreAllowed(state)) { + const policyDetail = /gpo-store only/i.test(state.netshOutput) + ? "Windows reports LocalFirewallRules as N/A (GPO-store only)." + : "Local firewall rules are disabled for the active profile."; + return { + applies: true, + severity: "warning", + code: "windows_firewall_local_rules_ignored", + message: "Windows Firewall may ignore local Gateway allow rules for this network profile.", + details: [ + `Active network profile: ${activeProfileText}.`, + `Matching local allow rule(s): ${formatRuleNames(programAgnosticLocalRules)}.`, + policyDetail, + "Use a Group Policy/administrator-managed inbound TCP allow rule for the Gateway port, or switch to a network path such as loopback, Tailscale, or an SSH tunnel.", + ], + }; + } + + if (mismatchedRules.length > 0) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_rule_profile_mismatch", + message: "Windows Firewall has a Gateway allow rule, but not for the active network profile.", + details: [ + `Active network profile: ${activeProfileText}.`, + `Mismatched allow rule(s): ${formatRuleNames(mismatchedRules)}.`, + "Create or update an inbound TCP allow rule for the active profile, or change the Windows network profile intentionally.", + ], + }; + } + + if (!localRulesAreAllowed(state) && state.localMatchingRules.length === 0) { + const policyDetail = /gpo-store only/i.test(state.netshOutput) + ? "Windows reports LocalFirewallRules as N/A (GPO-store only)." + : "Local firewall rules are disabled for the active profile."; + return { + applies: true, + severity: "warning", + code: "windows_firewall_local_rules_ignored", + message: "Windows Firewall may ignore local Gateway allow rules for this network profile.", + details: [ + `Active network profile: ${activeProfileText}.`, + "No active inbound TCP allow rule for the Gateway port was found.", + policyDetail, + "Use a Group Policy/administrator-managed inbound TCP allow rule for the Gateway port, or switch to a network path such as loopback, Tailscale, or an SSH tunnel.", + ], + }; + } + + if (blockingProfiles.length > 0 || activeProfiles.length === 0) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_no_allow_rule", + message: "Windows Firewall is likely blocking LAN devices from reaching the Gateway port.", + details: [ + `Active network profile: ${activeProfileText}.`, + "No enabled inbound TCP allow rule for the Gateway port was found in the active firewall policy.", + "Allow the Gateway port in Windows Firewall, or use loopback, Tailscale, or an SSH tunnel instead of LAN binding.", + ], + }; + } + + return { + applies: true, + severity: "info", + code: "windows_firewall_unrestricted", + message: "Windows Firewall did not show a blocking active profile for the Gateway port.", + details: [`Active network profile: ${activeProfileText}.`], + }; +} + +function buildClassifiedState( + stateJson: string, + netshOutput: string, + activeRules: FirewallRule[], + localRules: FirewallRule[], +): ClassifiedFirewallState | null { + return parseWindowsGatewayFirewallState({ + stateJson, + rulesJson: JSON.stringify({ + ActiveRules: activeRules, + LocalRules: localRules, + }), + netshOutput, + }); +} + +function shouldProbeManagedActiveRules(diagnostic: WindowsGatewayFirewallDiagnostic): boolean { + return ( + diagnostic.severity === "warning" && + diagnostic.code !== "windows_firewall_inbound_rules_disabled" + ); +} + +export function parseWindowsGatewayFirewallState(params: { + stateJson: string; + rulesJson: string; + netshOutput?: string | null; +}): ClassifiedFirewallState | null { + const state = parseJsonPayload(params.stateJson) as FirewallStatePayload | null; + const rules = parseJsonPayload(params.rulesJson); + if (!state) { + return null; + } + const rulePayload = + rules && typeof rules === "object" && !Array.isArray(rules) + ? (rules as { ActiveRules?: unknown; LocalRules?: unknown }) + : null; + return { + activeProfileNames: parseConnectionProfileNames(state.ConnectionProfiles), + activeProfiles: parseFirewallProfiles(state.ActiveFirewallProfiles), + localProfiles: parseFirewallProfiles(state.LocalFirewallProfiles), + matchingRules: parseFirewallRules(rulePayload ? rulePayload.ActiveRules : rules), + localMatchingRules: parseFirewallRules(rulePayload?.LocalRules), + netshOutput: params.netshOutput ?? "", + }; +} + +export async function inspectWindowsGatewayFirewall( + params: InspectWindowsGatewayFirewallParams, +): Promise { + const platform = params.platform ?? process.platform; + if (platform !== "win32" || params.bind !== "lan") { + return { + applies: false, + severity: "info", + code: "windows_firewall_not_applicable", + message: "Windows LAN firewall diagnostics do not apply.", + details: [], + }; + } + + const runCommandWithTimeout = params.runCommandWithTimeout ?? defaultRunCommandWithTimeout; + const mode = params.mode ?? "full"; + const timeoutMs = + params.timeoutMs ?? + (mode === "quick" + ? QUICK_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS + : DEFAULT_WINDOWS_GATEWAY_FIREWALL_TIMEOUT_MS); + if (mode === "quick") { + const quickJson = await runBestEffortCommand( + runCommandWithTimeout, + powershell(buildWindowsQuickFirewallCommand(params.port)), + timeoutMs, + ); + if (quickJson === null) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: "OpenClaw could not quickly inspect Windows Firewall LAN Gateway policy.", + details: [ + "Run `openclaw gateway status --deep` again, or verify the advertised LAN URL from another device.", + ], + }; + } + const quickPayload = parseJsonPayload(quickJson) as QuickFirewallPayload | null; + if (!quickPayload || typeof quickPayload !== "object" || Array.isArray(quickPayload)) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: "OpenClaw could not parse Windows Firewall LAN Gateway policy.", + details: [ + "Run `openclaw gateway status --deep` again, or verify the advertised LAN URL from another device.", + ], + }; + } + const managedActiveRules = parseFirewallRules(quickPayload.ActiveRules); + const localRules = parseFirewallRules(quickPayload.LocalRules); + const stateJson = JSON.stringify(quickPayload.State ?? null); + const policyState = parseWindowsGatewayFirewallState({ + stateJson, + rulesJson: JSON.stringify({ + ActiveRules: [], + LocalRules: [], + }), + }); + if (!policyState) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: "OpenClaw could not parse Windows Firewall LAN Gateway policy.", + details: [ + "Run `openclaw gateway status --deep` again, or verify the advertised LAN URL from another device.", + ], + }; + } + const activeRules = [ + ...managedActiveRules, + ...(localRulesAreAllowed(policyState) ? localRules : []), + ]; + const state = buildClassifiedState(stateJson, "", activeRules, localRules); + return state + ? classifyWindowsGatewayFirewallState(state) + : { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: "OpenClaw could not parse Windows Firewall LAN Gateway policy.", + details: [ + "Run `openclaw gateway status --deep` again, or verify the advertised LAN URL from another device.", + ], + }; + } + const [stateJson, rulesJson, netshOutput] = await Promise.all([ + runBestEffortCommand( + runCommandWithTimeout, + powershell(WINDOWS_FIREWALL_STATE_COMMAND), + timeoutMs, + ), + runBestEffortCommand( + runCommandWithTimeout, + powershell(buildWindowsFirewallRulesCommand(params.port)), + timeoutMs, + ), + runBestEffortCommand( + runCommandWithTimeout, + [getWindowsSystem32ExePath("netsh.exe"), "advfirewall", "show", "allprofiles"], + timeoutMs, + ), + ]); + + if (stateJson === null || rulesJson === null) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: "OpenClaw could not inspect Windows Firewall policy for LAN Gateway reachability.", + details: [ + "Run `openclaw gateway status --deep` from a normal PowerShell session and verify the advertised LAN URL from another device.", + ], + }; + } + const firewallPolicyText = netshOutput ?? ""; + const localRules = parseFirewallRules(parseJsonPayload(rulesJson)); + const policyState = parseWindowsGatewayFirewallState({ + stateJson, + rulesJson: JSON.stringify({ + ActiveRules: [], + LocalRules: [], + }), + netshOutput: firewallPolicyText, + }); + if (!policyState) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: "OpenClaw could not parse Windows Firewall policy for LAN Gateway reachability.", + details: [ + "Run `openclaw gateway status --deep` from a normal PowerShell session and verify the advertised LAN URL from another device.", + ], + }; + } + let activeRules = localRulesAreAllowed(policyState) ? localRules : []; + let state = buildClassifiedState(stateJson, firewallPolicyText, activeRules, localRules); + if (!state) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: "OpenClaw could not parse Windows Firewall policy for LAN Gateway reachability.", + details: [ + "Run `openclaw gateway status --deep` from a normal PowerShell session and verify the advertised LAN URL from another device.", + ], + }; + } + + const initialDiagnostic = classifyWindowsGatewayFirewallState(state); + if (shouldProbeManagedActiveRules(initialDiagnostic)) { + const managedRulesJson = await runBestEffortCommand( + runCommandWithTimeout, + powershell(buildWindowsManagedActiveFirewallRulesCommand(params.port)), + timeoutMs, + ); + if (managedRulesJson !== null) { + activeRules = [...activeRules, ...parseFirewallRules(parseJsonPayload(managedRulesJson))]; + state = buildClassifiedState(stateJson, firewallPolicyText, activeRules, localRules); + if (!state) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: "OpenClaw could not parse Windows Firewall policy for LAN Gateway reachability.", + details: [ + "Run `openclaw gateway status --deep` from a normal PowerShell session and verify the advertised LAN URL from another device.", + ], + }; + } + } else if (!localRulesAreAllowed(state)) { + return { + applies: true, + severity: "warning", + code: "windows_firewall_inspection_failed", + message: + "OpenClaw could not inspect managed Windows Firewall rules for LAN Gateway reachability.", + details: [ + "Run `openclaw gateway status --deep` from a normal PowerShell session and verify Group Policy or administrator-managed allow rules for the Gateway port.", + ], + }; + } + } + + const diagnosticBeforeLocalDetail = classifyWindowsGatewayFirewallState(state); + if (!localRulesAreAllowed(state) && diagnosticBeforeLocalDetail.severity !== "info") { + const localRulesJson = await runBestEffortCommand( + runCommandWithTimeout, + powershell(buildWindowsPersistentFirewallRulesCommand(params.port)), + Math.max(timeoutMs, 10_000), + ); + if (localRulesJson !== null) { + state.localMatchingRules = parseFirewallRules(parseJsonPayload(localRulesJson)); + } + } + + return classifyWindowsGatewayFirewallState(state); +} + +export function formatWindowsGatewayFirewallGuidance(params: { + bind: string | undefined; + platform?: NodeJS.Platform; +}): string[] { + const platform = params.platform ?? process.platform; + if (platform !== "win32" || params.bind !== "lan") { + return []; + } + return [ + "Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.", + ]; +} + +export function formatWindowsGatewayFirewallDiagnostic( + diagnostic: WindowsGatewayFirewallDiagnostic, +): string[] { + if (!diagnostic.applies || diagnostic.severity !== "warning") { + return []; + } + return [ + `Windows firewall: ${diagnostic.message}`, + ...diagnostic.details.map((line) => ` ${line}`), + ]; +} diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 75972eb53681..64c6483f0407 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -86,6 +86,15 @@ const startGatewayServer = vi.hoisted(() => close: vi.fn(async () => {}), })), ); +const inspectWindowsGatewayFirewall = vi.hoisted(() => + vi.fn<() => Promise>(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 }) => { diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index fd6bac7b7e1f..b1a7fa7cfca3 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -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)