Skip to content

Commit eb417fa

Browse files
authored
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
1 parent e798655 commit eb417fa

13 files changed

Lines changed: 2007 additions & 2 deletions

docs/web/control-ui.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ If the Gateway is running on the same computer, open:
2222

2323
If the page fails to load, start the Gateway first: `openclaw gateway`.
2424

25+
<Note>
26+
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.
27+
</Note>
28+
2529
Auth is supplied during the WebSocket handshake via:
2630

2731
- `connect.params.auth.token`

src/cli/daemon-cli/status.gather.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ const loadInstalledPluginIndexInstallRecords = vi.fn<
5959
const readGatewayRestartHandoffSync = vi.fn<
6060
(_env?: NodeJS.ProcessEnv) => GatewayRestartHandoff | null
6161
>(() => null);
62+
const inspectWindowsGatewayFirewall = vi.fn<(opts?: unknown) => Promise<unknown>>(async () => ({
63+
applies: false,
64+
severity: "info" as const,
65+
code: "windows_firewall_not_applicable",
66+
message: "Windows LAN firewall diagnostics do not apply.",
67+
details: [],
68+
}));
6269
const auditGatewayServiceConfig = vi.fn(async (_opts?: unknown) => undefined);
6370
const serviceIsLoaded = vi.fn(async (_opts?: unknown) => true);
6471
const serviceReadRuntime = vi.fn<
@@ -224,6 +231,10 @@ vi.mock("../../infra/tls/gateway.js", () => ({
224231
loadGatewayTlsRuntime: (cfg: unknown) => loadGatewayTlsRuntime(cfg),
225232
}));
226233

234+
vi.mock("../../infra/windows-gateway-firewall-diagnostics.js", () => ({
235+
inspectWindowsGatewayFirewall: (opts: unknown) => inspectWindowsGatewayFirewall(opts),
236+
}));
237+
227238
vi.mock("./probe.js", () => ({
228239
probeGatewayStatus: (opts: unknown) => callGatewayStatusProbe(opts),
229240
}));
@@ -281,6 +292,14 @@ describe("gatherDaemonStatus", () => {
281292
loadGatewayTlsRuntime.mockClear();
282293
inspectGatewayRestart.mockClear();
283294
inspectPortConnections.mockClear();
295+
inspectWindowsGatewayFirewall.mockClear();
296+
inspectWindowsGatewayFirewall.mockResolvedValue({
297+
applies: false,
298+
severity: "info",
299+
code: "windows_firewall_not_applicable",
300+
message: "Windows LAN firewall diagnostics do not apply.",
301+
details: [],
302+
});
284303
readGatewayRestartHandoffSync.mockClear();
285304
readConfigFileSnapshotCalls.mockClear();
286305
loadConfigCalls.mockClear();
@@ -335,6 +354,31 @@ describe("gatherDaemonStatus", () => {
335354
expect(status.cli?.entrypoint).toBe(process.argv[1]);
336355
}
337356
expect(inspectGatewayRestart).not.toHaveBeenCalled();
357+
expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
358+
});
359+
360+
it("includes Windows firewall diagnostics during deep LAN gateway status", async () => {
361+
inspectWindowsGatewayFirewall.mockResolvedValueOnce({
362+
applies: true,
363+
severity: "warning",
364+
code: "windows_firewall_local_rules_ignored",
365+
message: "Windows Firewall may ignore local Gateway allow rules for this network profile.",
366+
details: ["Windows reports LocalFirewallRules as N/A (GPO-store only)."],
367+
});
368+
369+
const status = await gatherDaemonStatus({
370+
rpc: {},
371+
probe: false,
372+
deep: true,
373+
});
374+
375+
expect(inspectWindowsGatewayFirewall).toHaveBeenCalledWith(
376+
expect.objectContaining({ bind: "lan", mode: "quick", port: 19001 }),
377+
);
378+
expect(status.gateway?.windowsFirewall).toMatchObject({
379+
severity: "warning",
380+
code: "windows_firewall_local_rules_ignored",
381+
});
338382
});
339383

340384
it("falls back to probe version when server metadata is unavailable", async () => {
@@ -653,6 +697,7 @@ describe("gatherDaemonStatus", () => {
653697
daemonLoadedConfig = {
654698
gateway: {
655699
mode: "remote",
700+
bind: "lan",
656701
remote: { url: "wss://gateway.example" },
657702
},
658703
};
@@ -664,6 +709,7 @@ describe("gatherDaemonStatus", () => {
664709
});
665710

666711
expect(inspectPortConnections).not.toHaveBeenCalled();
712+
expect(inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
667713
expect(loadInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
668714
expect(status.connections).toBeUndefined();
669715
expect(status.pluginVersionDrift).toBeUndefined();

src/cli/daemon-cli/status.gather.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ import {
4646
readGatewayRestartHandoffSync,
4747
type GatewayRestartHandoff,
4848
} from "../../infra/restart-handoff.js";
49+
import {
50+
inspectWindowsGatewayFirewall,
51+
type WindowsGatewayFirewallDiagnostic,
52+
} from "../../infra/windows-gateway-firewall-diagnostics.js";
4953
import { resolveConfiguredLogFilePath } from "../../logging/log-file-path.js";
5054
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-record-reader.js";
5155
import {
@@ -77,6 +81,7 @@ type GatewayStatusSummary = {
7781
controlUiLinks?: { httpUrl: string; wsUrl: string };
7882
probeNote?: string;
7983
version?: string | null;
84+
windowsFirewall?: WindowsGatewayFirewallDiagnostic;
8085
};
8186

8287
type PortStatusSummary = {
@@ -599,6 +604,16 @@ export async function gatherDaemonStatus(
599604
commandProgramArguments: command?.programArguments,
600605
rpcUrlOverride: opts.rpc.url,
601606
});
607+
const shouldInspectLocalGateway = daemonCfg.gateway?.mode !== "remote" && !probeUrlOverride;
608+
const windowsFirewall =
609+
opts.deep === true && shouldInspectLocalGateway
610+
? await inspectWindowsGatewayFirewall({
611+
bind: gateway.bindMode,
612+
mode: "quick",
613+
port: daemonPort,
614+
platform: process.platform,
615+
})
616+
: undefined;
602617
const { portStatus, portCliStatus } = await inspectDaemonPortStatuses({
603618
daemonPort,
604619
cliPort,
@@ -731,7 +746,7 @@ export async function gatherDaemonStatus(
731746
// diagnostics instead.
732747
// Best-effort: unreadable install records omit this advisory report.
733748
let pluginVersionDrift: PluginVersionDriftReport | undefined;
734-
if (daemonCfg.gateway?.mode !== "remote" && !probeUrlOverride) {
749+
if (shouldInspectLocalGateway) {
735750
try {
736751
const installRecords = await loadInstalledPluginIndexInstallRecords({
737752
env: mergedDaemonEnv as NodeJS.ProcessEnv,
@@ -767,6 +782,7 @@ export async function gatherDaemonStatus(
767782
},
768783
gateway: {
769784
...gateway,
785+
...(windowsFirewall?.applies ? { windowsFirewall } : {}),
770786
...(opts.probe
771787
? {
772788
version: gatewayVersion,

src/cli/daemon-cli/status.print.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,40 @@ describe("printDaemonStatus", () => {
183183
expectMockLineContains(runtime.log, "protocol mismatch after rollback");
184184
});
185185

186+
it("prints Windows firewall diagnostics in gateway status output", () => {
187+
printDaemonStatus(
188+
{
189+
service: {
190+
label: "LaunchAgent",
191+
loaded: true,
192+
loadedText: "loaded",
193+
notLoadedText: "not loaded",
194+
runtime: { status: "running", pid: 8000 },
195+
},
196+
gateway: {
197+
bindMode: "lan",
198+
bindHost: "0.0.0.0",
199+
port: 18789,
200+
portSource: "env/config",
201+
probeUrl: "ws://127.0.0.1:18789",
202+
windowsFirewall: {
203+
applies: true,
204+
severity: "warning",
205+
code: "windows_firewall_local_rules_ignored",
206+
message:
207+
"Windows Firewall may ignore local Gateway allow rules for this network profile.",
208+
details: ["Windows reports LocalFirewallRules as N/A (GPO-store only)."],
209+
},
210+
},
211+
extraServices: [],
212+
},
213+
{ json: false, deep: true },
214+
);
215+
216+
expectMockLineContains(runtime.error, "Windows firewall: Windows Firewall may ignore");
217+
expectMockLineContains(runtime.error, "GPO-store only");
218+
});
219+
186220
it("uses service command env for WSL systemd unavailable hints", () => {
187221
const originalPlatform = process.platform;
188222
Object.defineProperty(process, "platform", { value: "linux" });

src/cli/daemon-cli/status.print.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean; d
223223
if (status.gateway.probeNote) {
224224
defaultRuntime.log(`${label("Probe note:")} ${infoText(status.gateway.probeNote)}`);
225225
}
226+
if (status.gateway.windowsFirewall?.severity === "warning") {
227+
defaultRuntime.error(warnText(`Windows firewall: ${status.gateway.windowsFirewall.message}`));
228+
for (const detail of status.gateway.windowsFirewall.details) {
229+
defaultRuntime.error(warnText(` ${detail}`));
230+
}
231+
}
226232
spacer();
227233
}
228234

src/commands/configure.wizard.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => {
3636
resolveAdvertisedControlUiLinks: vi.fn(),
3737
resolveControlUiLinks: vi.fn(),
3838
resolveLocalControlUiProbeLinks: vi.fn(),
39+
inspectWindowsGatewayFirewall: vi.fn(),
3940
summarizeExistingConfig: vi.fn(),
4041
promptAuthConfig: vi.fn(),
4142
promptGatewayConfig: vi.fn(),
@@ -91,6 +92,16 @@ vi.mock("../infra/control-ui-assets.js", () => ({
9192
ensureControlUiAssetsBuilt: mocks.ensureControlUiAssetsBuilt,
9293
}));
9394

95+
vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({
96+
inspectWindowsGatewayFirewall: mocks.inspectWindowsGatewayFirewall,
97+
formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) =>
98+
params.bind === "lan"
99+
? [
100+
"Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.",
101+
]
102+
: [],
103+
}));
104+
94105
vi.mock("../wizard/clack-prompter.js", () => ({
95106
createClackPrompter: mocks.createClackPrompter,
96107
}));
@@ -232,6 +243,13 @@ function setupBaseWizardState(config: OpenClawConfig = {}) {
232243
httpUrl: "http://127.0.0.1:18789/",
233244
wsUrl: "ws://127.0.0.1:18789",
234245
});
246+
mocks.inspectWindowsGatewayFirewall.mockResolvedValue({
247+
applies: false,
248+
severity: "info",
249+
code: "windows_firewall_not_applicable",
250+
message: "Windows LAN firewall diagnostics do not apply.",
251+
details: [],
252+
});
235253
mocks.summarizeExistingConfig.mockReturnValue("");
236254
mocks.createClackPrompter.mockReturnValue({
237255
intro: vi.fn(async () => {}),
@@ -409,6 +427,24 @@ describe("runConfigureWizard", () => {
409427
);
410428
});
411429

430+
it("shows static Windows Firewall guidance for LAN Gateway links without inspection", async () => {
431+
setupBaseWizardState({
432+
gateway: {
433+
mode: "local",
434+
bind: "lan",
435+
auth: { token: "token" },
436+
},
437+
});
438+
439+
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
440+
441+
expect(mocks.inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
442+
expect(mocks.note).toHaveBeenCalledWith(
443+
expect.stringContaining("Windows firewall: if another device cannot connect to the LAN URL"),
444+
"Control UI",
445+
);
446+
});
447+
412448
it("exits with code 1 when configure wizard is cancelled", async () => {
413449
const runtime = createRuntime();
414450
setupBaseWizardState();

src/commands/configure.wizard.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { logConfigUpdated } from "../config/logging.js";
1818
import { ConfigMutationConflictError } from "../config/mutate.js";
1919
import type { OpenClawConfig } from "../config/types.openclaw.js";
2020
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
21+
import { formatWindowsGatewayFirewallGuidance } from "../infra/windows-gateway-firewall-diagnostics.js";
2122
import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js";
2223
import type { RuntimeEnv } from "../runtime.js";
2324
import { defaultRuntime } from "../runtime.js";
@@ -884,12 +885,14 @@ export async function runConfigureWizard(
884885
const gatewayStatusLine = gatewayProbe.ok
885886
? "Gateway: reachable"
886887
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
888+
const windowsFirewallLines = formatWindowsGatewayFirewallGuidance({ bind });
887889

888890
note(
889891
[
890892
`Web UI: ${displayLinks.httpUrl}`,
891893
`Gateway WS: ${displayLinks.wsUrl}`,
892894
gatewayStatusLine,
895+
...windowsFirewallLines,
893896
"Docs: https://docs.openclaw.ai/web/control-ui",
894897
].join("\n"),
895898
"Control UI",

0 commit comments

Comments
 (0)