feat(networking): Windows L2Bridge egress with router-safe VFP ACLs (opt-in) - #144
feat(networking): Windows L2Bridge egress with router-safe VFP ACLs (opt-in)#144luthermonson wants to merge 2 commits into
Conversation
…opt-in)
NAT cannot software-filter Windows container egress — VFP does not engage on
an HNS NAT network, so per-endpoint Switch ACLs are inert there. This adds an
opt-in L2Bridge path that binds containers to a host NIC via a NetAdapterName
network policy, giving each container a VFP-managed vSwitch port where ACLs
actually enforce. Proven on metal (static IP); DHCP is the v1 IPAM choice.
The router-safe ACL ladder (buildL2BridgeEgressACLPolicies, a pure function)
matches the Linux end-state: block ALL of 10/8, 172.16/12, 192.168/16,
169.254/16 — whole supernets, no gateway or own-subnet carve-out — and permit
only the internet. VFP is default-DENY once any ACL is present, so the ladder
is, by precedence (lower number wins):
90 Allow DHCP (UDP 67/68, Out+In) — lease/renew survives the block
95 Allow extra-allowed CIDRs (future use; default none)
100 Block the RFC1918 + link-local supernets, Out
65500 Allow 0.0.0.0/0 Out AND In — both mandatory (the In allow keeps
TCP SYN-ACK return traffic alive; without the pair the port
default-denies everything, internet included)
DNS is set to public resolvers on the endpoint so the container never needs
the LAN router for name resolution. The rule set is static (independent of the
leased gateway/DNS) and applied at endpoint creation, before the container
starts — fail-closed, no post-lease window. Any ApplyPolicy error tears down
the endpoint and refuses the job.
NAT stays the default and is untouched: buildEgressBlockPolicies and the NAT
init/setup path are unchanged, and the new ladder is only reached when a pool
sets network.l2bridge_egress = true. The NAT block-only builder is deliberately
NOT repurposed — its whole-supernet block with no carve-out would blackhole the
NAT gateway (10.88.0.1, inside 10/8) if VFP ever engaged.
Config: network.l2bridge_egress (bool), network.host_nic (required when on),
network.public_dns (default 1.1.1.1/8.8.8.8), network.extra_allowed_destinations.
On-metal validation of this branch on mfl-win-amd64-101 (Server 2025 26100)
found the router-safe ladder blocked EVERYTHING — the internet included —
rather than only RFC1918. Bisecting the rule set on the same endpoint and the
same code path, with only these two rules varying:
blocks + allow-any + UDP 67/68 allows -> every probe fails: 1.1.1.1:443,
8.8.8.8:53, DNS, public HTTPS, and
all RFC1918 targets
blocks + allow-any -> the intended posture exactly:
Grafana 192.168.10.45:3000, Incus
192.168.12.113:8443, Proxmox
192.168.5.1/.2:8006, the LAN router
192.168.1.1:80/443 and the ephemerd
host all blocked, while 1.1.1.1:443,
8.8.8.8:53, DNS-by-name and public
HTTPS all work
Controls run alongside: the two allow-any rules alone leave everything
reachable (so Allow rules do work and the port is not default-denied by their
mere presence), and the original by-hand 7-policy set still enforces
selectively (so the mechanism is intact). The only variable that turns a
working ladder into a total blackout is the pair of port-scoped DHCP allows.
HNS accepts them — ApplyPolicy returns success — but the VFP rule set it
produces drops all traffic, and an explicit higher-precedence Allow for the
gateway does not survive it either. This fails closed (breaks jobs) rather
than open (leaks), but it makes the feature unusable.
Nothing on this path needs them: the endpoint is addressed by HNS IPAM, not by
a DHCP client inside the container. Removed, with a regression test asserting
every rule in the ladder carries an address scope and none carries a port
scope.
Also corrects two comments that no longer match reality: installFirewallRules
is not a no-op (firewall_windows.go programs Hyper-V firewall rules, though
hyperVEgressRules builds them for DefaultSubnet/defaultGateway, so on L2Bridge
they match nothing), and the DHCP-IPAM claim on initL2Bridge is wrong — HNS
rejects an L2Bridge network with no Ipams outright:
hcnCreateNetwork failed in Win32: The network does not have a subnet for
this endpoint. (0x803b0005) / ErrorCode 2151350277
so no network, endpoint or container can be created while that path is taken.
That is recorded as UNRESOLVED rather than fixed here: giving the network a
subnet works (HNS then assigns endpoint addresses itself, and the rest of the
path was verified on metal), but it means handing containers real LAN
addresses, which needs an allocation range the LAN's DHCP server will not also
hand out. That is a design decision, not a code fix.
On-metal validation —
|
| ACL set | Grafana :3000 |
Incus :8443 |
Proxmox .1/.2:8006 |
Router 192.168.1.1:80/443 |
ephemerd host :135 |
1.1.1.1:443 |
8.8.8.8:53 |
DNS by name | public HTTPS |
|---|---|---|---|---|---|---|---|---|---|
| none (baseline control) | OPEN | OPEN | OPEN | OPEN | OPEN | OPEN | OPEN | OK | HTTP 200 |
| allow-any only | OPEN | OPEN | OPEN | OPEN | OPEN | OPEN | OPEN | OK | HTTP 200 |
| ladder as generated | blocked | blocked | blocked | blocked | blocked | blocked | blocked | FAIL | FAIL |
ladder + explicit 192.168.1.1/32 allow @95 |
blocked | blocked | blocked | blocked | blocked | blocked | blocked | FAIL | FAIL |
| ladder minus the DHCP rules | blocked | blocked | blocked | blocked | blocked | OPEN | OPEN | OK | HTTP 200 |
| original by-hand 7-policy set | blocked | blocked | blocked | OPEN | OPEN | OPEN | OPEN | OK | HTTP 200 |
The "allow-any only" row shows Allow rules work and the port is not default-denied just by having ACLs. The by-hand row shows the enforcement mechanism is intact. The only variable that turns a working ladder into a total blackout is the pair of port-scoped UDP 67/68 allows — and note that with them present even an explicit higher-precedence Allow for the gateway does not survive.
HNS accepts them (ApplyPolicy returns success); the VFP rule set it produces drops everything. It fails closed rather than open, so it breaks jobs instead of leaking — but it makes the feature unusable.
Nothing here needs them: the endpoint is addressed by HNS IPAM, not by a DHCP client in the container. Removed in 138a737, with a regression test asserting every rule in the ladder is address-scoped and none is port-scoped.
The good news
Once those two rules are gone, the router-safe model does exactly what it claims — and this is the part that had never been validated. Blocking the whole of 192.168.0.0/16 including the container's own subnet and its default gateway does not break egress: the container still routes through 192.168.1.1 as a next hop while being unable to address it. Grafana, Incus, both Proxmox hosts, the LAN router and the ephemerd host itself are all unreachable; 1.1.1.1, 8.8.8.8, DNS-by-name via the public resolvers, and public HTTPS all work. That's a strictly better posture than the by-hand proof, which left the router and host reachable.
Mechanistic tell
vfpctrl /list-vmswitch-port enumerates the container's port on the L2Bridge switch (it errors on NAT):
Port name : E37525A9-E8DA-428A-83EF-C8F6111C07AB
Switch Friendly name : ephemerd-l2bridge
Port type : Synthetic
MAC address : 00-15-5D-6D-BE-D7
VM name : l2base-ctr@vm
Command list-vmswitch-port succeeded!
Also worth a look
setup() claimed installFirewallRules is a no-op. It isn't — firewall_windows.go programs Hyper-V firewall rules — but hyperVEgressRules is called with DefaultSubnet/defaultGateway, so on L2Bridge, where the container holds a LAN address, those rules match nothing. The per-endpoint ACLs really are the only thing enforcing. Comment corrected; the NAT-subnet scoping is left as-is since it's the NAT path's concern.
Not merging — #1 still needs an IPAM decision from you.
Problem
Windows container egress cannot be software-filtered on the current HNS NAT
network. VFP (the Virtual Filter Platform) does not engage on a NAT network, so
the per-endpoint Switch ACLs ephemerd already applies are inert — enforcement
there rests entirely on the Hyper-V firewall. We need an enforcement point where
software ACLs actually bite.
Fix (opt-in, NAT stays default)
Add an L2Bridge networking path, gated behind a per-pool flag
(
network.l2bridge_egress). On L2Bridge the container gets a VFP-managedvSwitch port, so per-endpoint ACLs enforce. This was proven on metal
(node
mfl-win-amd64-1, Server 2025): with the correct ACL model, aHyper-V-isolated container reached
1.1.1.1/ public HTTPS whilecontainer→Grafana/Incus/Proxmox (RFC1918) was blocked.
Merging this flips nothing — NAT remains the default and the NAT init/setup/
ACL path is untouched. A pool only reaches L2Bridge by setting
l2bridge_egress = true.The router-safe ACL model (the load-bearing change)
VFP is default-DENY the moment any ACL is present — a pure-Block set (today's
buildEgressBlockPolicies) would black-hole the internet too.buildL2BridgeEgressACLPolicies(a pure, unit-tested function) emits this precedence ladder as
RuleType=SwitchVFP ACLs (lower Priority = higher precedence, first match wins):
This matches the Linux end-state (
firewall_linux.go): block all of RFC1918including the LAN router, permit only the internet.
On L2Bridge the container is a LAN peer, so carving out the subnet would expose
the same-subnet management plane and the router. This is the fix for the
same-subnet management-plane bug.
default-denies everything; the inbound allow is the non-obvious one — drop it
and every outbound TCP connect fails when the SYN-ACK returns.
Dns.ServerListis set to aconfigurable public resolver (default
1.1.1.1,8.8.8.8), so DNS rides the0.0.0.0/0allow and the container never needs the LAN router. No allow tothe gateway/router.
access, which we block.
Fail-closed, no post-lease window
The ladder is static — it does not depend on the leased gateway or DNS (DNS is
public, the router gets no allow) — so the ACLs are applied at endpoint creation,
before the container starts. Any
ApplyPolicyerror tears down the endpoint andrefuses the job.
Network shape
L2Bridge network is
hcn.L2Bridgebound to the host NIC via ahcn.NetAdapterNamenetwork policy (recipe §1). IPAM = DHCP: the network declares no static subnet/
routes and the endpoint carries no
IpConfigurations, so the container leases itsIP, gateway, and default route from the LAN DHCP server. Network name is
ephemerd-l2bridge(distinct from the NATephemerd).Config surface (what mayfly must render)
New
[network]keys (per-pool → rendered into the per-VM ephemerdconfig.toml,same flow as the existing
[vm.linux]toggle andwindows_runnersizing):l2bridge_egress(bool, defaultfalse) — the opt-in.host_nic(string) — required whenl2bridge_egress = true; the host adaptername to bridge onto. No default (host-specific; do not hardcode "Ethernet 2",
the spike's hot-added test NIC). Missing → init fails closed.
public_dns([]string, default["1.1.1.1","8.8.8.8"]).extra_allowed_destinations([]string, default[]) — future use.Wired through
networking.Config(L2BridgeEgress,HostNIC,PublicDNS,ExtraAllowedCIDRs) at bothnetworking.Newcall sites incmd/ephemerd/main.go.Documented in
config.example.toml. mayfly rendering is a follow-up (not inthis PR): add the four keys to the per-pool Windows config schema and render them
into
[network];host_nicshould be a required field when the pool opts in.Honest caveats
enforcement with a static IP. DHCP is the chosen v1 IPAM and will be validated
at the gated cutover. If DHCP-on-L2Bridge misbehaves there, static-pool IPAM is
the proven fallback (recipe §6 recommends static-first).
the 2-tier (high-prio blocks over low-prio allow-any) was. The default posture here
has no carve-outs (empty
extra_allowed_destinations), so the default set is theproven shape plus the DHCP allow.
v0.14.0-rc.1'shcndoes not expose aready source-IP/MAC anti-spoof endpoint/port policy in a form I could apply without
guessing the schema, so it is not implemented here rather than faked. On L2Bridge
the container is a routable LAN peer that could source-spoof within the L2 domain;
egress enforcement rests on the VFP ACLs. Follow-up: evaluate an HNS
L2BridgeMAC/IP anti-spoof port policy.
Deviations from the recipe
buildEgressBlockPoliciesfor NAT. Rewriting it into theladder (or applying the ladder on NAT) would blackhole the NAT gateway
10.88.0.1(inside the whole-
10/8block, no carve-out) if VFP ever engaged. Kept the NATblock-only builder and path exactly as-is; the ladder is a new function on
the L2Bridge path only. This better satisfies "NAT stays intact".
IpConfigurations— per the user's DHCP v1 choice; see caveat above.Tests
pkg/networking/network_windows_test.go(pure, no HCN calls):TestL2BridgeEgressACLPolicies_LadderShape— both allow-any (Out+In), both DHCPallows, all four supernets blocked; exact protocols/priorities/directions.
TestL2BridgeEgressACLPolicies_NoGatewayOrSubnetCarveOut— no allow to any RFC1918address, blocks are plain CIDRs (no exclusion ranges leaked in).
TestL2BridgeEgressACLPolicies_Precedence— DHCP/extra < block < allow-any.TestL2BridgeEgressACLPolicies_ExtraAllowed— carve-outs emitted Out, above block.TestBuildEgressBlockPolicies(NAT) unchanged and passing.go build ./pkg/networking/... ./pkg/config/...,go vet ./pkg/networking/..., andgo test ./pkg/networking/...all clean (nativeGOOS=windows). (go build ./...hits a pre-existing missing-embed-artifact error in
pkg/vm, unrelated to this change.)Not in this PR
No live node touched. The live cutover is a separate, gated step.