Skip to content

docs(networking): WFP IPFORWARD egress is a dead end on WinNAT (negative result + evidence) - #143

Open
luthermonson wants to merge 1 commit into
mainfrom
feat/windows-wfp-egress
Open

docs(networking): WFP IPFORWARD egress is a dead end on WinNAT (negative result + evidence)#143
luthermonson wants to merge 1 commit into
mainfrom
feat/windows-wfp-egress

Conversation

@luthermonson

@luthermonson luthermonson commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What this is

A negative result, proven on the live Windows node, plus the evidence and a reproducer. Windows container egress cannot be enforced by a host-side WFP filter on this Server 2025 HNS NAT + Hyper-V-isolated stack. This closes a fifth mechanism after four prior host-side failures, and it means the fix for the Windows platform is network-level (an isolated VLAN), not the daemon.

No daemon behavior changes: this PR adds docs/arch/windows-egress-wfp-investigation.md and records the finding in firewall_windows.go so nobody starts a sixth attempt from scratch.

The mechanism that was tested

The Linux analogue of ephemerd's FORWARD-chain block (firewall_linux.go) is a WFP BLOCK filter at FWPM_LAYER_IPFORWARD_V4 — no callout driver, no kernel signing. Built and driven on mfl-win-amd64-101 via the pure-Go github.com/tailscale/wf binding. Scoping was by the ephemerd NAT vSwitch arrival interface (robust to pre/post-SNAT) with an RFC1918 destination allow-list carving out the container subnet.

Field mapping had to be corrected against the live layer: on this build IPFORWARD_V4 exposes no IP_ARRIVAL_INTERFACE — the arrival interface is IP_LOCAL_INTERFACE and the destination is IP_DESTINATION_ADDRESS.

Why the prior four failed (context)

  1. Host firewall source-scoped localip=10.88/16 (sec(networking): host-firewall egress backstop for Windows job containers #136) — WinNAT rewrites source before host ALE; also host-level would block the node's own RFC1918.
  2. New-NetFirewallHyperVRule by VMCreatorId (fix(networking): enforce Windows container egress at the Hyper-V firewall #140) — no container VMCreator exists on this host.
  3. HNS endpoint ACLs — applied but not enforced (VFP not managing the NAT switch ports).
  4. Route blackhole / in-container firewall — proxy-ARP; MpsSvc can't start in-container.

The experiments (all on metal, branch test/containment-suite)

Single-IP experiments targeted Grafana (192.168.10.45) only — blocking it doesn't disconnect the runner, so "Grafana blocked, other three reachable" would have been the clean scoped success.

Layer Conditions Result
IPFORWARD_V4 dest=Grafana AND arrival if9 reached
IPFORWARD_V4 dest=Grafana only reached
INBOUND_IPPACKET_V4 dest(IP_LOCAL_ADDRESS)=Grafana AND arrival if9 reached
matrix of 7 (inbound local/remote × if9/any, ipforward dest × if9/any, outbound remote post-NAT) all Grafana reached

Containment Windows egress log (matrix run, 7 filters installed)

FAIL: reached Proxmox coyotes at 192.168.5.1:8006
FAIL: reached Proxmox kings at 192.168.5.2:8006
FAIL: reached Incus daemon at 192.168.12.113:8443
FAIL: reached Grafana at 192.168.10.45:3000

All four reached — none of the seven filters caught the container. The load-bearing one is the post-NAT OUTBOUND_IPPACKET_V4 dest-only block: after SNAT the container's packet must leave on ifIndex 7 with dest 192.168.10.45, and it still wasn't stopped.

Note: because nothing was blocked, the containment job's "management planes unreachable" step fails and its subsequent "internet still reachable" step is skipped by fail-fast — so there is no green containment log to paste, by design. The runner's own GitHub connection (internet) worked throughout every run.

Positive controls — WFP is enforced here

Same layer/field/value pointed at the host's own traffic:

ALE_AUTH_CONNECT_V4 block IP_REMOTE_ADDRESS=1.1.1.1  -> host->1.1.1.1:443 reachable=False
OUTBOUND_IPPACKET_V4 block IP_REMOTE_ADDRESS=1.1.1.1 -> host->1.1.1.1:443 reachable=False
(after provider removed)                             -> host->1.1.1.1 restored=True

The engine blocks traffic that reaches it — it blocked the host. It just never sees the container's SNAT'd packet.

Datapath facts that explain it

  • Get-NetNat — empty (the HNS NAT is not a NetNat object).
  • Per-interface Forwarding is Disabled on both the NAT vSwitch (if9) and the LAN NIC (if7).

The NAT + forward happens inside the Hyper-V vSwitch / HNS datapath, out-of-band of the host tcpip.sys WFP hooks.

Conclusion / recommendation

Host-side software (WFP included) cannot enforce Windows container egress on this stack. Use network-level isolation — put the Windows runner on a VLAN / managed switch whose uplink denies the RFC1918 management ranges. The per-endpoint HNS ACLs stay as a best-effort layer for Windows builds where VFP does manage the ports, but on this build they are inert and must not be relied on.

Reproducer + full evidence: docs/arch/windows-egress-wfp-investigation.md. Binding used: github.com/tailscale/wf (pure Go, no driver); intentionally not added to go.mod since there is no working mechanism to ship.

…nce)

Windows container egress cannot be enforced by a host-side WFP filter on the
Server 2025 HNS NAT + Hyper-V-isolated stack. Built and drove a pure-Go
tailscale/wf spike on the live node: a BLOCK filter at FWPM_LAYER_IPFORWARD_V4
scoped to the NAT vSwitch arrival interface (and destination) does not drop the
container's forwarded/SNAT'd egress. Neither does INBOUND_IPPACKET_V4 (arrival
scoped or not) nor a dest-only block at OUTBOUND_IPPACKET_V4 post-NAT.

Positive controls prove WFP is enforced on the host (blocking the host's own
1.1.1.1 at ALE and OUTBOUND both worked), so the container's packets travel the
Hyper-V vSwitch/HNS datapath out-of-band of the host tcpip.sys WFP hooks
(Get-NetNat empty, per-interface Forwarding Disabled). This closes a fifth
mechanism after the four prior host-side attempts; the fix for this platform is
network-level (isolated VLAN denying RFC1918), not a daemon-side filter.

Adds docs/arch/windows-egress-wfp-investigation.md with the full method,
containment logs, positive controls, and a reproducer, and records the finding
in firewall_windows.go so no sixth attempt starts from scratch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant