sec(networking): host-firewall egress backstop for Windows job containers - #136
Merged
Conversation
…ners Windows jobs could reach the fleet's management planes (Incus, Grafana) because installFirewallRules was a no-op on Windows and the per-endpoint HNS/VFP ACLs alone did not contain a Hyper-V-isolated job on the real runner (found by the containment suite, #134). Implement the Windows counterpart of firewall_linux.go: Windows Defender Firewall rules installed on the host, where WinNAT routes every container flow regardless of vSwitch policy. Outbound blocks cover RFC1918 + link-local with the container subnet (gateway, DNS, NAT, container-to- container) subtracted via range arithmetic — Windows Firewall has no rule ordering and Block beats Allow, so the gateway can never appear inside a blocked range. Inbound rules block container→gateway traffic on the ephemerd control ports, mirroring controlPlaneInputRules. Rules are named ephemerd-egress-* and installed delete-before-add, so reinstallation is idempotent and Cleanup removes the exact set. Install failure surfaces as an error that callers already treat as a warning, so a host without firewall privileges degrades to the endpoint ACLs instead of refusing to start. Closes #135
luthermonson
added a commit
that referenced
this pull request
Aug 8, 2026
…wall (#140) #136 blocked container->LAN egress with host Windows Defender Firewall rules (netsh advfirewall) scoped to the container subnet as source. On real hardware this is a no-op: the host firewall (WFP/MPSSVC) evaluates NATed container egress post-NAT, where the source is the host's own LAN address, so a rule scoped to 10.88/16 matches nothing. Confirmed against a live v0.1.6 Windows node -- the containment suite still reached Proxmox, Incus, and Grafana. Replace it with Hyper-V firewall rules (New-NetFirewallHyperVRule), which filter at the container vNIC boundary BEFORE NAT, where the packet still carries the container's own address. Rules are scoped by -VMCreatorId (discovered at runtime via Get-NetFirewallHyperVVMCreator, excluding the well-known WSL creator) so they apply to job containers, not the host or unrelated Hyper-V workloads. The gateway (DNS/NAT/default route) and the container-to-container range are carved out of every blocked range up front (subtractCIDR), so they never appear in a Block rule -- no reliance on rule-priority ordering, which is the kind of unverified firewall semantic that sank #136. The internet, including GitHub/Docker Hub/registries, is untouched. Idempotent (remove-before-add), findable/removable by the ephemerd- name prefix, and graceful: hosts without the Hyper-V firewall cmdlets fall back to the netsh rules, and any failure warns instead of failing daemon startup. netsh construction is retained as that fallback. Needs a live run on the Server 2025 node to confirm; the containment suite's Windows step is the regression gate.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Windows job containers had unrestricted LAN egress. The containment suite (#134) reached the fleet's Incus daemon (
192.168.12.113:8443) and Grafana (192.168.10.45:3000) from a Hyper-V-isolated job. Linux jobs are contained byfirewall_linux.go's iptables FORWARD chain;installFirewallRuleson Windows was a no-op, and the per-endpoint HNS/VFP ACLs applied insetup()did not contain the job on the real runner.Mechanism: host-side Windows Defender Firewall rules
firewall_windows.gois the counterpart offirewall_linux.go:installFirewallRulesprograms Windows Defender Firewall on the host vianetsh advfirewall,removeFirewallRulesdeletes the same set.Why host firewall rather than (more) HNS ACLs:
The per-endpoint ACLs stay in place as defense in depth.
What is allowed through, and why
10.88.0.0/16(which contains the NAT gateway10.88.0.1and the other containers) is subtracted from each blocked range with IPv4 range arithmetic (subtractCIDR), e.g.10.0.0.0/8becomes10.0.0.0-10.87.255.255,10.89.0.0-10.255.255.255. Windows Firewall has no rule ordering — Block always beats Allow — so the Linux "allow gateway above the deny" pattern cannot be ported; a blocked gateway could not be rescued by an allow rule, and blocking it would brick all container networking (DNS and outbound NAT both go through it). Carving it out of the block set is the only correct shape.10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16(minus the subnet), so GitHub, Docker Hub, and package registries are unaffected.hostFirewallRulesalso emits inbound TCP blocks (source = container subnet, dest = gateway, one specific port each) forcfg.ControlPorts— the mirror ofcontrolPlaneInputRuleson Linux. DNS (53) and NAT are deliberately not matched.localip=<container subnet>: forwarded container traffic is evaluated pre-NAT with its10.88/16source, so the host's own management-LAN traffic can never match — a mis-scoped rule degrades to a no-op, never to cutting the fleet host off its own LAN.Idempotency and cleanup
Every rule has a deterministic name under the
ephemerd-egress-prefix (ephemerd-egress-block-10.0.0.0_8,ephemerd-egress-control-10000, …). Install is delete-before-add per name, so re-runninginstallFirewallRulesnever accumulates duplicates;removeFirewallRulesrecomputes the same set and deletes each name (best-effort, Debug-logged), same as the Linux removal path. The set is findable withnetsh advfirewall firewall show rule name=all | findstr ephemerd-egress.Failure mode
installFirewallRulesreturns an error on the first failednetshadd; every caller (cmd/ephemerd/main.go:271,424) already logs a warning and continues, so a host where the daemon lacks firewall privileges degrades to the per-endpoint ACLs instead of refusing daemon startup — identical posture to Linux.Tests
TestSubtractCIDR— table test of the range subtraction (split, no-overlap, full-cover, edge-aligned, malformed, IPv6-rejected).TestHostFirewallRules_*— pure rule-set assertions without invoking netsh, mirroringfirewall_linux_test.go: every denied range becomes an outbound block; the subnet/gateway never appear in a blocked range; control-port rules are narrow (TCP, one port, subnet→gateway, never 53); names/argv contract for idempotent delete-before-add.go build ./...,go test ./pkg/networking/...,GOOS=windows go vet ./pkg/networking/,GOOS=linux go vet ./pkg/networking/all pass (built and run natively on Windows).The real regression test is the containment suite's Windows "Fleet management planes must be unreachable" step (
.github/workflows/containment.yml, #134), which fails today and should pass with this deployed on the Windows node.Closes #135