Skip to content

feat(networking): Windows L2Bridge egress with router-safe VFP ACLs (opt-in) - #144

Merged
luthermonson merged 10 commits into
mainfrom
feat/windows-l2bridge-egress
Aug 15, 2026
Merged

feat(networking): Windows L2Bridge egress with router-safe VFP ACLs (opt-in)#144
luthermonson merged 10 commits into
mainfrom
feat/windows-l2bridge-egress

Conversation

@luthermonson

@luthermonson luthermonson commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem

Windows job containers have no enforced egress filtering. A live container
can reach Grafana, the Incus API, and both Proxmox hosts. On Linux this is
enforced by nftables (pkg/networking/firewall_linux.go); on Windows the
zero-config HNS NAT stack makes software filtering impossible — VFP does not
engage on a NAT network, so the per-endpoint Switch ACLs ephemerd already
applies are inert.

Changing the container network type to L2Bridge is the only working path:
there VFP is engaged and HNS Switch-ACLs actually enforce. (WFP, the Hyper-V
firewall on NAT, ICS, VFP-on-NAT and NDIS LWF are all settled dead ends — see
docs/ and #143. Do not re-chase them.)

What merges (opt-in; NAT stays the default)

An L2Bridge networking path behind network.l2bridge_egress, default false.
The NAT init/setup/ACL path is untouched — a pool only reaches L2Bridge by
opting in.

The router-safe ACL ladder (proven on metal)

VFP is default-DENY the moment any ACL is present, so a pure-Block set would
blackhole the internet too. buildL2BridgeEgressACLPolicies emits, as
RuleType=Switch ACLs (lower Priority = higher precedence, first match
wins
):

Priority Action Dir Remote Proto Purpose
90 Allow Out this host /32 256 only when dind / module proxy is on — see below
95 Allow Out extra-allowed CIDR 256 operator carve-outs (default: none)
100 Block Out 10/8, 172.16/12, 192.168/16, 169.254/16 256 RFC1918 + link-local, whole supernets
65500 Allow Out 0.0.0.0/0 256 the internet
65500 Allow In 0.0.0.0/0 256 TCP return traffic (SYN-ACK)
  • Whole supernets, no carve-out — not even for the container's own subnet or
    its own default gateway. Containers still route through the gateway as a
    next hop while being unable to address it. A "block 192.168/16 except my own
    subnet" carve-out would exempt the management planes, which sit on the same
    /20 as the containers. Verified blocked from inside a container: Grafana,
    Incus, both Proxmox hosts, the router (:80/:443), and the ephemerd host.
    Verified still working: 1.1.1.1:443, 8.8.8.8:53, DNS, https://example.com
    → 200.
  • Both allow-any rules are mandatory. Without the pair the port
    default-denies everything; the inbound one is the non-obvious half — drop
    it and every outbound TCP connect dies when the SYN-ACK returns.
  • Every rule is address-scoped. Two port-scoped DHCP allows (UDP 67/68, no
    address scope) blackholed the entire VFP port on metal: ApplyPolicy
    returned success and nothing egressed at all, not even an explicitly allowed
    destination. 138a737 removed them; a regression test now fails the build if
    any ladder rule carries a port scope.
  • DNS → public resolvers on the network and endpoint, so name resolution
    rides the 0.0.0.0/0 allow and never needs the blocked LAN router.

Resolved: IPAM (this was the blocker)

The branch previously assumed DHCP IPAM. That is impossible. Metal, 2026-08-12:

  • With no Ipams declared, HNS refuses to create the network at all:
    hcnCreateNetwork ... The network does not have a subnet for this endpoint (0x803b0005). No network → no endpoint → no container.
  • With a subnet declared, HNS assigns endpoint addresses itself, with no
    IpConfigurations — four runs produced four unrelated addresses scattered
    across the declared prefix. On a real LAN that is the site DHCP server's scope.

So the network must declare a subnet, and ephemerd must own allocation.

network.ip_pool is now required whenever l2bridge_egress = true, with no
default.
It declares the slice of the LAN that the operator's DHCP server is
configured never to lease. ephemerd allocates from it (lowest-free-first,
idempotent per container, released on teardown) and pins each endpoint with an
explicit IpConfiguration at the LAN's own prefix length. Endpoints left behind
by a previous run are reserved when an existing network is adopted.

Nothing is defaulted to any particular network, and no built-in guess exists:

key required? default
l2bridge_egress false (NAT)
host_nic yes, when opted in none — host-specific
ip_pool yes, when opted in none, by design
subnet no derived from the address on host_nic
gateway no derived from that adapter's IPv4 default route
public_dns no 1.1.1.1, 8.8.8.8
extra_allowed_destinations no [] (strict)

Accepted ip_pool forms: a CIDR ("192.0.2.192/27" — network and broadcast
excluded) or an inclusive range ("192.0.2.200-192.0.2.230"). Examples
throughout the code, config, and tests use RFC 5737 documentation addresses;
nothing encodes anyone's real LAN.

Failure modes, all fail-fast, none guessing:

  • ip_pool unset → config load fails, before any HNS object exists:
    network.ip_pool is required when network.l2bridge_egress = true: on L2Bridge, job containers are addressed on this host's own LAN rather than behind NAT, so ephemerd must be told which addresses it may hand out. Set it to a range your DHCP server is configured never to lease — either a CIDR (ip_pool = "192.0.2.192/27") or an inclusive range (ip_pool = "192.0.2.200-192.0.2.230") — sized for at least runner.max_concurrent containers. There is no default: any built-in guess would collide with live DHCP leases
  • host_nic unset → config load fails, naming the key and Get-NetAdapter.
  • adapter not found / has no routable IPv4 → networking init fails, naming
    network.host_nic.
  • adapter has no default route → fails, telling the operator to set
    network.gateway.
  • pool outside subnet, or containing this host or the gateway → fails, naming
    network.ip_pool and the offending address.
  • pool exhausted at container setup → the job is refused, naming
    network.ip_pool and runner.max_concurrent. Never a silent fallback to an
    HNS-picked address.
  • ACL programming fails → the endpoint is deleted, the address released, and the
    job refused. Fail closed: no network beats an unfiltered one.

Resolved: the 10.88.0.1 dind binding

Manager.GatewayIP() derived the NAT gateway from the container subnet, and
three things bind to it: the per-job dind Docker API listener
(pkg/dind/listen_windows.go, handed to the job as DOCKER_HOST), the Go
module proxy
(GOPROXY), and dind's fake bridge network metadata. On
L2Bridge no interface holds 10.88.0.1, so net.Listen fails outright and
every job fails to provision — the same shape of failure as the ICS
experiment.

Two changes:

  1. platformNetworking gained hostAddr(). On L2Bridge the Windows platform
    reports the host's own adapter address, so the listeners bind to
    something that exists. NAT and Linux/macOS return "" and keep the existing
    subnet arithmetic — behaviour there is byte-identical.
  2. Binding is not enough: the host is inside RFC1918 and the ladder blocks it.
    The ladder therefore emits one address-scoped /32 allow for the host,
    gated on AllowHostAccess (set from dind.enabled || module_proxy.enabled).
    With neither feature on, the host stays unreachable — the strictest posture,
    exactly as verified on metal.

The /32 allow opens every port the host listens on, not just ephemerd's,
because a port-scoped Switch ACL blackholes the whole VFP port. The port
precision moves to the host firewall instead: l2BridgeControlPlaneRules emits
inbound blocks scoped localip=<host> / localport=<control port> /
remoteip=<ip_pool>. That scoping works here and did not work under NAT
(#136) for exactly the documented reason — L2Bridge does not NAT, so the
container's own address is still on the packet when it reaches the host.

The NAT-oriented Hyper-V firewall rules are deliberately not installed on
this path: hyperVEgressRules subtracts the container subnet from every blocked
range, and on L2Bridge the container subnet is the management LAN, so the
subtraction would carve the management plane straight back out of the deny.

Tests

All pure functions, no HCN/netsh calls. pkg/networking/l2bridge_test.go and
pkg/config/network_test.go carry no build tag, so they run on Linux CI too.

Address plan / IPAM (new, platform-independent):

  • TestParseIPPool_Forms / _Rejects — both accepted forms, network+broadcast
    exclusion, /31 and /32, and every malformed spec rejected.
  • TestIPAllocator_AllocatesWithinPool — every address inside the pool, no
    duplicates, exhaustion is an error naming network.ip_pool.
  • TestIPAllocator_ReservesHostAndGateway, _ReleaseAndIdempotence — host and
    router never handed out; retried setup reuses its address; release returns it.
  • TestResolveL2BridgePlan_DerivesFromHost / _ExplicitOverridesWin — subnet
    and gateway read off the adapter, pins honoured, derivation flagged.
  • TestResolveL2BridgePlan_Failures — 10 subtests, one per failure mode, each
    asserting the message names the key to fix.
  • TestResolveL2BridgePlan_NoBuiltInNetworkguard against re-introducing a
    hardcoded LAN
    : with no host information the plan must fail, not invent one.

ACL ladder + firewall + binding (Windows):

  • TestL2BridgeEgressACLPolicies_LadderShape, _NoGatewayOrSubnetCarveOut,
    _Precedence, _ExtraAllowed — unchanged, still passing.
  • TestL2BridgeEgressACLPolicies_EveryRuleIsAddressScoped — the port-scope
    regression guard from 138a737, now also covering the host allow. Not weakened.
  • TestL2BridgeEgressACLPolicies_HostAllow / _NoHostAllowByDefault — the /32
    is above the block, address-scoped, and absent by default.
  • TestL2BridgeControlPlaneRules / _NoPlanNoRules — inbound, blocked, scoped
    to host+pool+port, names distinct from the NAT rules, silent when unscopable.
  • TestWindowsHostAddr_L2BridgeReportsHostIP — the provisioning regression
    guard; also asserts NAT still yields 10.88.0.1.
  • TestWindowsHostAllowIP_GatedOnAllowHostAccess.

Config:

  • TestNetworkValidate_L2BridgeRequiresIPPool / _RequiresHostNIC /
    _MinimalConfig / _OptionalOverrides / _NotRequiredWhenOptedOut /
    _BlankStringsAreNotSet.

go build ./..., go vet ./pkg/... ./cmd/..., go test ./pkg/networking/... ./pkg/config/... ./pkg/dind/... all clean on native GOOS=windows;
GOOS=linux and GOOS=darwin cross-builds and GOOS=linux go vet clean.

Honest caveats — what still needs metal

  • Explicit IpConfigurations on an L2Bridge endpoint is not metal-verified.
    The metal run let HNS auto-assign. Pinning the address is the documented HNS
    approach and is what win-bridge CNI does, but the first gated run must confirm
    HNS accepts the pinned address and that the container's route table comes out
    right (ipconfig, default route via the LAN gateway, ping out).
  • The host /32 allow tier was not run end-to-end on metal. The 2-tier
    ladder was. Needs: docker version from inside a job (dind reachable) while
    the control ports remain refused from the same container.
  • Get-NetRoute -InterfaceAlias gateway derivation is untested on a live
    adapter; the fallback is network.gateway.
  • Go's net.InterfaceByName on Windows uses the adapter friendly name, the
    same string HNS NetAdapterName wants. Consistent by construction, but worth
    confirming on the first run with a renamed adapter.
  • Anti-spoof: still a documented gap. hcsshim v0.14.0-rc.1 exposes no
    ready source-IP/MAC anti-spoof policy in a form I could apply without guessing
    the schema. On L2Bridge a container is a routable LAN peer that could
    source-spoof within the L2 domain; egress enforcement rests on the VFP ACLs.

Migration — required, and NOT done here

No live node was touched. In particular the kings node
(mfl-win-amd64-101 / Proxmox VM 101) is left alone; it is processing
production jobs on v0.1.10.

Enabling this on a host that already has the HNS NAT network requires a
reboot, not just a daemon restart
. Creating/deleting an L2Bridge network
alongside a live NAT network has been observed to wedge HNS: job containers ran
with no egress at all, JIT runners registered then stayed offline,
Stop-Service ephemerd hung on graceful drain, and recovery needed a Proxmox
QMP reset.

Cutover order:

  1. Reserve the ip_pool range on the DHCP server (or place it outside the
    scope) first. Duplicate-address conflicts on the LAN otherwise.
  2. Cordon and drain the node.
  3. Render l2bridge_egress, host_nic, ip_pool (mayfly follow-up: add the
    keys to the per-pool Windows schema; host_nic and ip_pool required when
    the pool opts in).
  4. Reboot the host, then start ephemerd and check the
    L2Bridge address plan resolved log line before uncordoning.
  5. Run the containment suite.

Not in this PR

mayfly rendering of the new keys. Live validation. Anti-spoof.

@luthermonson

Copy link
Copy Markdown
Contributor Author

On-metal validation — mfl-win-amd64-101 (Proxmox VM 101, Server 2025 26100)

Ran this branch on the real node with a second vNIC hot-added on the LAN bridge (net1, enumerated as Ethernet 2, removed again afterwards). Containers were Hyper-V-isolated, on the already-cached ephpm/ephemerd:runner-ci-windows, created through networking.New()Manager.Setup() so the endpoint received the generated ACLs — nothing hand-applied on the treatment run. The production daemon, its NAT network ephemerd, and the nightly were left alone.

Two blocking findings

1. DHCP IPAM cannot create the network at all. initL2Bridge declares no Ipams; HNS rejects that outright:

creating HCN L2Bridge network on "Ethernet 2": hcnCreateNetwork failed in Win32:
The network does not have a subnet for this endpoint. (0x803b0005)
{"Success":false,"ErrorCode":2151350277}

No network, no endpoint, no container. This is the first thing that happens when a pool sets l2bridge_egress = true, so as merged the feature cannot start a job. Give the network a subnet + default route and everything downstream works — HNS then assigns endpoint addresses itself with no IpConfigurations (it handed out 192.168.0.19, 192.168.5.114, 192.168.14.56, 192.168.15.65 across runs). I did not switch IPAM over: it means handing containers real LAN addresses, which needs a range the LAN DHCP server won't also hand out. That's your call, so it's recorded as UNRESOLVED in the code.

2. The two DHCP ACLs blackhole the port. With a subnet in place so the rest of the path could run, the generated ladder blocked everything, internet included. Bisected on the same endpoint and code path, only these two rules varying:

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.

…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.
DHCP IPAM is not available on an HNS L2Bridge network. Declaring no Ipams
fails at network creation on metal (Server 2025 26100):

    hcnCreateNetwork failed in Win32: The network does not have a subnet
    for this endpoint. (0x803b0005)

so no network, no endpoint, no container. The network must carry a subnet
— and once it has one, HNS assigns endpoint addresses itself, from
anywhere inside that prefix. On a real LAN that is the site DHCP server's
scope: four container runs landed on four unrelated addresses.

So ephemerd allocates the addresses. A new required key, network.ip_pool,
declares the range the operator's DHCP server never leases; each endpoint
is pinned with an explicit IpConfiguration out of that pool, released on
teardown, and endpoints left by a previous run are reserved on adopt.
There is deliberately no default pool and nothing assumes any particular
network: subnet comes from the address on host_nic, gateway from that
adapter's IPv4 default route, and either can be pinned. Missing ip_pool or
host_nic fails at config load, naming the key; a pool outside the subnet,
or one swallowing the host or the router, fails at networking init.

Also fixes container provisioning on this path. Manager.GatewayIP() drove
the per-job dind listener and the Go module proxy to the NAT gateway
10.88.0.1, which exists on no interface once the NAT network is gone —
both would fail to bind and every job would fail to provision. The
platform now reports the host's own L2Bridge address instead, and the ACL
ladder gains a single address-scoped /32 allow for it, emitted only when
dind or the module proxy is enabled. That allow opens every port the host
listens on, because a port-scoped Switch ACL blackholes the whole VFP
port; the control-plane ports are fenced back off with inbound host
firewall rules scoped to the pool, which match here only because L2Bridge
does not NAT (the reason that approach failed under NAT in #136).

The proven ACL ladder is otherwise unchanged: whole-supernet RFC1918 +
link-local blocks with no gateway or own-subnet carve-out, over the
mandatory allow-any Out+In floor, every rule address-scoped.

Still opt-in, NAT still the default, and no live node touched. Switching
an existing node needs a reboot, not a daemon restart.
It is only called from network_windows.go, so on non-Windows builds it
tripped the unused linter and failed CI. The address arithmetic it feeds
stays in l2bridge.go, untagged, so it keeps building and running under
test on every platform.
@luthermonson
luthermonson force-pushed the feat/windows-l2bridge-egress branch from 7ab66c8 to 40d69af Compare August 14, 2026 03:02
`ephemerd run` constructed its own networking.Config, so a local run on a
host configured for L2Bridge egress got the default instead: an HNS NAT
network plus the NAT-era netsh host-firewall rules. Two consequences, both
seen on a live node. The job landed UNFILTERED on NAT even though the host
was deliberately configured to filter, and the netsh rules block RFC1918
host-wide -- on a host whose DNS resolver is a LAN address that severs the
host's own name resolution, which showed up as image pulls failing with
"no such host" and then hanging at zero bytes.

The run now loads config.toml once and passes the [network] settings
through workflow.Runner, matching what serve does field for field,
including AllowHostAccess via needsHostAccess so dind keeps working. On
L2Bridge it adopts the existing network and reserves the addresses of
endpoints already on it, so it cannot hand a job an address the service is
already using. A missing or malformed config is still not fatal: the run
falls back to the built-in default network and, on Windows, warns that the
job's egress is unfiltered.

Also documents the platform split honestly in the security guide. It
previously claimed Windows enforces the same block list via HCN ACLs,
which is false on the NAT path -- those ACLs are a VFP construct and VFP
is not engaged on a NAT switch. The guide now states plainly that the
Windows default does not enforce, why no software mechanism can on that
stack, and what L2Bridge requires: a wired adapter, because a Wi-Fi
station cannot carry container MACs, and a reserved ip_pool with no
default because any guess would collide with live DHCP leases.
@luthermonson

Copy link
Copy Markdown
Contributor Author

Metal test 2026-08-13: deployed to a live node, broke all Windows jobs, rolled back

Deployed this branch (rebased onto main, v0.1.10-4-g40d69af) to the win-amd64 node and ran it for ~2h against real CI. Do not merge as-is.

What worked

  • Address plan derives correctly with nothing hardcoded:
    L2Bridge address plan resolved host_nic=Ethernet subnet=192.168.0.0/20 subnet_derived=true gateway=192.168.1.1 gateway_derived=true host_ip=<host> ip_pool=<pool>/28 pool_size=14
  • Pool allocation works. Real job containers received sequential pool addresses (.241, .242, .243) with the correct gateway and /20 prefix.
  • The ACL ladder is programmed exactly as designed on live endpoints — Block Out on 10/8, 172.16/12, 192.168/16, 169.254/16 at priority 100; Allow 0.0.0.0/0 Out and In at 65500; host /32 at priority 90 when dind is enabled. Every rule address-scoped, no port-scoped rules.
  • VFP is engaged (vfpctrl /list-vmswitch-port enumerates the container ports, which it cannot do on NAT).

What broke

Every Windows job fails. Containers start, JIT runners register, and then the runner never connects — GitHub reports status: offline indefinitely while the job stays queued and the container keeps running (13+ minutes observed before intervention).

It is not the ACLs:

  • Container ports report 0 VFP drops. Nothing is being filtered.
  • The container is unreachable at layer 2 from its own host: Get-NetNeighbor <container-ip> returns LinkLayerAddress 00-00-00-00-00-00, State Unreachable.
  • The endpoint has correct IPAddress, GatewayAddress, PrefixLength and DNSServerList, and passes no traffic at all.

Suspected cause

The explicit IpConfigurations pinning added in this branch. The 2026-08-12 hand proof — same ladder, same host, working internet — let HNS auto-assign endpoint addresses. Pinning is the delta, and it yields an endpoint that exists but is dead.

MAC handling is a live suspect: the endpoint carried its own MAC, distinct from the host adapter's, even though L2Bridge is supposed to rewrite container MACs to the host's. The node is itself a VM, so the upstream virtual switch may be dropping frames with an unexpected source MAC.

Clean A/B on the same node, same jobs, same repo:

network runners result
L2Bridge quick_pasteur, nice_pascal offline, container L2-unreachable
NAT (after rollback) eager_keller, stoic_turing online, busy, executing within ~3 min

Also found

  • Stop-Service hangs on graceful drain during rollback, because the stuck jobs can never finish. Set the service to Manual, kill the process, then swap.
  • Restarting ephemerd does not tear down the HNS network. windowsNetworking.cleanup() is a no-op; Manager.Cleanup() only removes host firewall rules, and only on a clean shutdown. initL2Bridge adopts an existing network by name rather than re-deriving it — so any change to the underlying subnet requires explicitly deleting the HNS network first, or it returns pinned to the old subnet, looks healthy, and routes nothing.

Before this can merge

  1. Fix endpoint wiring so a pinned address actually passes traffic — verify against MAC rewriting and the upstream switch's handling of container MACs.
  2. Re-verify on metal that a Windows job container reaches GitHub and is blocked from RFC1918. Neither half has been demonstrated together on the deployed configuration.
  3. Verify the claim now in docs/guides/security.md that L2Bridge requires a wired adapter because 802.11 cannot carry multiple MACs. If L2Bridge rewrites MACs, that reasoning is wrong and the doc needs correcting.

The 2026-08-13 deployment died with every container unreachable: runners
registered, then sat offline while their jobs stayed queued. pktmon told
the whole story -- the guest was configured and ARPing for its gateway,
the router's reply arrived at the NIC, and the vSwitch dropped it as
"Invalid Packet" before the container port, every single time.

A bisect harness (recreating the proven 2026-08-12 hand run through the
daemon's containerd, one field varied per run) isolated the cause to a
single fact about this HNS build (Server 2025, 26100): a Switch-rule ACL
with Priority below 100 silently kills the endpoint's entire VFP
dataplane. It is independent of the rule's action, direction, and
address -- an irrelevant Block of a documentation IP at priority 90
reproduces the dead port, and the identical ladder with every priority
at or above 100 works. HNS accepts the policy without error either way.
The ladder's host-allow tier sat at priority 90 (and the extra-allow
tier at 95), so any endpoint with dind enabled came up dead. The
earlier "port-scoped DHCP rules blackhole the port" finding was in the
same band (90/95) and was likely this trap as well.

Re-tier the ladder to 100 (host allow) / 150 (extra allows) / 200
(RFC1918+link-local blocks) / 65500 (allow-any Out+In). Same precedence
order, nothing below 100. A regression test pins both the constants and
every emitted rule above the floor.

Two more fixes from the same investigation:

- Creating the L2Bridge network consumes the host NIC: it re-enumerates
  as "vEthernet (<name>)", so every daemon restart after the first
  failed plan resolution and crash-looped. Adapter lookup (address and
  gateway both) now tries the vEthernet-renamed form, and the unwrapped
  form for operators who configured the vEthernet name.

- Endpoint IpConfigurations no longer set PrefixLength. HNS derives it
  from the network's subnet; the proven hand run and Microsoft's own
  sdnbridge CNI both omit it.

Validated live on the win-amd64 node: with the re-tiered ladder a real
CI job's runner came online in under a minute and the job executed on
the L2Bridge network, ACLs enforcing (harness probe: internet up,
management plane blocked). Same node, same jobs, old ladder: dead.
@luthermonson

Copy link
Copy Markdown
Contributor Author

Root cause found, fixed, and validated live (c197490)

The dead-endpoint failure from the 2026-08-13 deployment is solved. It was none of the suspects from the previous comment.

Root cause: a VFP Switch-rule ACL with Priority below 100 silently kills the endpoint's entire dataplane (Server 2025, build 26100). The port drops every inbound frame — pktmon shows the gateway's ARP reply arriving from the wire and dying as Invalid Packet at the vSwitch, 91/91 attempts — so the container never resolves its next hop. HNS applies the policy without error and reports the endpoint healthy. The effect is independent of the rule's action, direction, and address: an irrelevant Block 203.0.113.1/32 at priority 90 reproduces it, and the identical ladder with every priority ≥ 100 works. This ladder's host-allow tier sat at 90 (extra-allow at 95), so any endpoint with dind enabled came up dead.

Found with a bisect harness (cmd/l2test, untracked) that recreates the proven 2026-08-12 hand run through the daemon's own containerd with each production delta behind a flag, ~2 min per run:

case shape verdict
A0 exact proof shape PASS — no environment/version drift
B0 exact production shape FAIL — harness reproduces the bug
A1 production ACL ladder only FAIL — ladder is the killer
A1b ladder minus the 192.168/16 block FAIL — not the block scope
A1c ladder minus the priority-90 rule PASS
blk90 irrelevant Block @90, nothing else changed FAIL — priority band, not the rule
blk200 same rule at @200 PASS — third tier is fine
fix allow@100 / blocks@200 / allow-any@65500 PASS: internet up, Grafana blocked

The commit re-tiers the ladder to 100/150/200/65500 (same precedence order, nothing below 100) and adds a regression test pinning both the constants and every emitted rule above the floor. Two more fixes from the same investigation: adapter lookup now tolerates the vEthernet (<name>) rename that creating the network causes (previously every daemon restart after first boot crash-looped), and endpoint IpConfigurations no longer set PrefixLength (HNS derives it; the proof and Microsoft's sdnbridge CNI both omit it).

Live validation on the win-amd64 node: with the fixed ladder, a real Windows CI build job's runner came online in under a minute and the job ran to success on the L2Bridge network — pinned pool address, MAC-rewrite observed working, live endpoint ladder verified at 100/200/65500. Same node and job under the old ladder: dead. Also re-observed en route: pinned IpConfigurations work fine (guest fully configured), exonerating the previous comment's prime suspect.

Still open before merge: dind-over-L2Bridge not yet exercised by a real job (the build job doesn't use docker); a new orphan-sweep race that half-deleted a provisioning job's runner dir at daemon startup (separate bug, separate fix); primary-NIC (shared) binding untested with the fixed ladder — the live node runs on a dedicated second NIC.

The orphan sweep decides "orphan" by the absence of a containerd container.
But Create copies the ~200MB runner dir (job-<id>) and can then spend
minutes pulling a cold Windows image before it calls NewContainer — the
whole time, the job has on-disk state but no container. A sweep firing in
that window (startup CleanOrphans racing the startup poll, or the periodic
SweepOrphans) deleted a live job's runner dir out from under it, half-
removing node.exe and leaving the runner in a "path not found" self-update
loop. Observed on the win-amd64 node 2026-08-14.

Track in-flight IDs on the Runtime: Create registers its ID before the
copy and clears it on return; SweepOrphans unions those IDs into its keep
set. Closes the window regardless of provisioning duration. CleanOrphans
stays nil-keyed — it is startup-only, before any provisioning begins.

Also documents the dedicated-NIC recommendation (creating the L2Bridge
migrates the host IP onto a vEthernet adapter; doing that on a remote
node's only NIC risks unreachability) in config.example.toml and the
security guide, and corrects the security guide's wired-adapter claim: the
egress ACLs rewrite container source MACs to the host NIC's, so the
"Wi-Fi can't carry extra MACs" reasoning was wrong. Wi-Fi is now called
out as untested rather than impossible.
An adversarial in-job probe on the win-amd64 node showed egress containment
working perfectly (LAN + every management plane blocked, internet up) but
`docker version` timing out. The VFP host /32 allow lets a container's
packet leave its port toward the host, but the host's OWN inbound Windows
Firewall default-denies it, so the per-job dind Docker API listener — bound
to the host's LAN address — was never reachable. A timeout, not a refusal,
confirmed the drop was at the host firewall, not the socket.

Add a scoped inbound allow, opened when the Windows dind listener binds and
removed when the job's server stops: dir=in action=allow protocol=TCP
localip=<host> localport=<dind port> remoteip=<ip_pool>. It opens exactly
that one dynamic port to exactly the container pool — a blanket host allow
would expose RDP/SMB/RPC to job containers, which the strict posture must
not. Plumbed through networking.Manager.OpenHostPort/CloseHostPort; no-op
on NAT and on Linux/macOS (containers reach the bridge gateway directly
there). A unit test pins the rule's scope so a future change can't widen it
to localport=any.
…tdown

Per-job dind host-port allows are removed by dind's CloseHostPort on a
graceful job stop, but a hard kill (Stop-Process) skips that, and
removeL2BridgeFirewallRules only deleted the control-plane rules by their
computed names -- so a hard-killed job left its inbound allow behind, and
they accumulated. Sweep the ephemerd-egress-l2b-hostport-* prefix via the
firewall cmdlets (netsh delete-by-name has no wildcard) on every
removeFirewallRules, so both shutdown and the next startup Cleanup reclaim
any leaked allows. Runs regardless of whether a plan currently resolves.
The default NAT Windows path installed a Hyper-V-firewall rule set and a
netsh host-firewall fallback, plus a block-only per-endpoint ACL set. All
three were proven ineffective on real hardware: runhcs NAT containers
register no Hyper-V VMCreator (so the rules bind to nothing), the host
firewall sees NAT'd egress post-NAT (so a container-source-scoped rule
matches nothing), and VFP does not engage on a NAT vSwitch (so the ACLs are
inert). It was enforcement theater — a NAT node looked protected while its
containers reached the whole LAN.

Delete it. On the default NAT network ephemerd now installs nothing and
logs plainly that container egress is NOT filtered, pointing the operator
at network.l2bridge_egress (the only path that actually enforces, via HNS
L2Bridge + per-endpoint VFP Switch ACLs). No security is lost — none of the
removed code ever blocked anything.

Removes ~975 lines: hyperVRule/hyperVEgressRules/discoverContainerVMCreators/
enableHyperVFirewallScript/removeByPrefixScript/hyperVFirewallAvailable and
the WSL creator const; hostFirewallRules/installNetshFirewallRules/
removeNetshFirewallRules and their CIDR-subtraction helpers; and
buildEgressBlockPolicies with its NAT applyACLPolicies branch. The L2Bridge
path is untouched — the two branch cleanly on cfg.L2BridgeEgress — and the
shared helpers it depends on (psQuote, powershell, netsh, winFirewallRule,
egressBlockedCIDRs) are retained. Stale tests for the deleted code removed.

Also corrects the docs the strip made honest: the firewall_windows.go
header no longer frames the Hyper-V firewall as the "primary path," and
docs/arch/windows-egress-wfp-investigation.md gains an addendum noting a
host-side software path (L2Bridge VFP ACLs) was later found and shipped, so
its "network-level only" conclusion applies to the NAT stack, not
universally.
@luthermonson
luthermonson marked this pull request as ready for review August 15, 2026 02:20
@luthermonson
luthermonson merged commit c4f4281 into main Aug 15, 2026
4 checks passed
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