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

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();

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,

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" });

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();
}