docs(vpn): stop crediting the nftables kill-switch for a drop it never makes
Four places in the tree told the reader that the host nftables table is the authoritative fail-closed backstop for egress-class=vpn pods: platform/vpn-egress README, egress-steering.yaml, the role README, and the vpn-killswitch test's own GIVEN/THEN prose. It is not, and cannot be — Cilium's eBPF egress-gateway datapath bpf_redirect()s those skbs to the egress device without invoking netfilter POSTROUTING. Corrected with the measurement in each place rather than softened, because a guard the repo trusts and that does not fire is worse than no guard. What replaces the claim, everywhere: vpn-class fails closed because CILIUM drops the traffic when it cannot use its cached egress interface, verified by tcpdump on both the WAN and tun0 during a tunnel stop and a tunnel restart (0 packets out either). The nft table keeps a narrower, now-proven job: node-originated traffic sourced from a tunnel address (killswitch_drop moved 0 -> 3 packets on k3s-prod-3 when such a packet was routed at the WAN). Also recorded: * egress-steering.yaml — `interface: tun0` is resolved ONCE and cached, and the gateway node is what tells Cilium it changed (the egress-uplink node label). A future reader's instinct will be to have the node write to that object; the comment says not to, because it is Flux-managed, and points at the label and `cilium-dbg bpf egress list` as the debugging entry points instead. * egress-steering.yaml — why the new NFS media mounts are not affected by pod-scoped steering: an NFS PV is mounted by the node kernel in the host netns, so podSelector never matches it. The 192.168.0.0/16 exclusion covers the server too, but that is the second reason, not the first. * vpn-killswitch test — its recovery step depends on the re-steer path, and could not have passed before it existed. Its no-leak assertion was always right; only the explanation was wrong. Also states the prerequisite plainly: the test cannot run at all without a labelled gateway node. * platform README — prod is live on k3s-prod-3 with the before/after egress IPs, and the note that prod's vpn-class pods had NO internet beforehand (CEGP with no matching node -> `Gateway IP: Not Found` -> dropped), so arming the gateway restored egress rather than moving it off the WAN. * role README — the standing "add the shared fwmark to the interface contract" escalation is closed as not implementable on Cilium OSS, rather than left open for someone to attempt. DECISIONS-LOG carries the full session entry, including the drafted ADR-0015 / docs-03 amendment for the architect (specs are immutable to agents) and the five things left for the operator — chief among them that a silent gateway outage still has no alert, which is what let the dev gateway sit dead for 80 minutes. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
60e3f09c67
commit
04606d48b2
5 files changed
+479
-53
No files matched your search
@@ -12,12 +12,19 @@ separate GitOps authored against the shared **interface contract** below.
|
||||
> once the tunnel is live in dev/test.
|
||||
|
||||
## Interface contract (shared with the Cilium-policy agent)
|
||||
- **Inventory group** `vpn_egress_gateway` — the egress-gateway node(s) (initially `k3s-dev-3`).
|
||||
- **Inventory groups** — `vpn_egress_gateway` (dev: `k3s-dev-3`) and `vpn_egress_gateway_prod`
|
||||
(prod: `k3s-prod-3`). Each holds **exactly one** host, and prod is a separate group so the
|
||||
flagless playbook run cannot reach it. Cilium OSS resolves a multi-match nodeSelector to "the
|
||||
first node in lexical ordering", so a second labelled node does not add failover — it silently
|
||||
steals the role.
|
||||
- **k8s node label** `homelab.keithjackson.dev/egress-gateway=true` — set by this role; the
|
||||
`CiliumEgressGatewayPolicy` selects the gateway node by it.
|
||||
`CiliumEgressGatewayPolicy` selects the gateway node by it. **This label is what arms the
|
||||
datapath**, which is why the role applies it LAST.
|
||||
- **Host tunnel iface** `tun0` — the Cilium policy SNATs vpn-class pods out of it.
|
||||
- **Optional fwmark** `0x1c8` — a contract *hook* (see kill-switch layering) the Cilium side
|
||||
MAY stamp on vpn-class packets for a tunnel-down-independent host backstop.
|
||||
- **k8s node label** `homelab.keithjackson.dev/egress-uplink=<tun0 addr>-<ifindex>` — set by this
|
||||
role's re-steer script. Its VALUE is the uplink Cilium was last told about; its CHANGE is what
|
||||
makes Cilium re-resolve `interface: tun0`. Cilium caches that resolution and never revisits it
|
||||
on its own, so without this the gateway is dead after its first reconnect. See "Re-steering".
|
||||
|
||||
## How the four ADR-0015 invariants are met
|
||||
|
||||
@@ -45,19 +52,42 @@ Two independent layers, both scoped so they can only ever affect vpn-class traff
|
||||
2. **nftables backstop (`templates/vpn-egress-killswitch.nft.j2`).** Own `inet
|
||||
vpn_egress_gateway_killswitch` table, base chain **policy `accept`** (so it can only *drop* the
|
||||
narrow matches, never break anything). In `postrouting`:
|
||||
- `oifname tun0 ip saddr @vpn_egress_gateway_src accept` — tunnel up: let steered traffic out.
|
||||
- `ip saddr @vpn_egress_gateway_src oifname != tun0 drop` — **fail closed:** a tunnel-sourced packet
|
||||
trying to leave any other interface is dropped (no WAN leak). This rule stays **armed even
|
||||
while the tunnel is down** because the source set is retained on tunnel-down (see below).
|
||||
- `meta mark 0x1c8 oifname != tun0 drop` — the optional fwmark backstop.
|
||||
- `oifname tun0 ip saddr @vpn_egress_gateway_src counter accept` — tunnel up: let
|
||||
tunnel-sourced traffic out.
|
||||
- `ip saddr @vpn_egress_gateway_src oifname != tun0 counter drop` — **fail closed:** a
|
||||
tunnel-sourced packet trying to leave any other interface is dropped (no WAN leak). Armed
|
||||
even while the tunnel is down, because the source set is retained on tunnel-down (below).
|
||||
|
||||
`@vpn_egress_gateway_src` holds the **last live tun0 address**. The up-script **flushes then
|
||||
re-adds** the current tunnel source on every connect; the down-script **intentionally retains
|
||||
it** (see "Explicit fail-closed backstop" below). Node traffic and `internet`-class traffic are
|
||||
never in the set, never carry the mark, and are never targeted — so they always hit
|
||||
**Both rules carry a `counter`, and both were exercised on k3s-prod-3 (2026-09-12):** a
|
||||
source-bound `ping -I 10.72.192.7` with the ip-rule present got 3/3 replies at 144 ms and
|
||||
moved `killswitch_accept`; with the ip-rule deleted, `ip route get` confirmed the packet
|
||||
would go `via 192.168.2.1 dev eno1` and `killswitch_drop` moved **0 -> 3 packets / 252
|
||||
bytes** with 100% loss. The kill-switch is real, not aspirational — but read the next
|
||||
paragraph for what it can and cannot reach.
|
||||
|
||||
**SCOPE, HONESTLY — it does NOT cover vpn-class POD traffic.** A counters-only diagnostic
|
||||
table on the dev gateway recorded, while a vpn-class pod egressed: 0 packets in `forward`
|
||||
and 0 in `postrouting` for the pod CIDR *and* for the PIA range, against 45166 packets on
|
||||
that same postrouting hook for WAN traffic and 16 for a node-originated curl out tun0.
|
||||
Cilium's eBPF egress-gateway datapath (`bpf.masquerade: true`) `bpf_redirect()`s those skbs
|
||||
straight to the egress device, so netfilter POSTROUTING is never invoked for them. What
|
||||
makes vpn-class fail closed is **Cilium's own drop** when the cached egress interface is
|
||||
unusable — tcpdump during a tunnel stop and during a tunnel restart showed 0 packets leaving
|
||||
either the WAN or tun0. This table's real, narrow guarantee is NODE-originated traffic
|
||||
sourced from a tunnel address.
|
||||
|
||||
`@vpn_egress_gateway_src` holds **every tun0 address this node has had**. The up-script **adds**
|
||||
the current tunnel source on every connect and never flushes (it used to flush first, which left
|
||||
a brief moment where a packet still sourced from the previous address matched neither the
|
||||
ip-rule nor the drop rule); the down-script **intentionally retains it**. Node traffic and
|
||||
`internet`-class traffic are never in the set and are never targeted — so they always hit
|
||||
`policy accept` and **fail open toward the WAN** (invariant 4), tunnel up or down. The opposite
|
||||
directions are structural, not incidental.
|
||||
|
||||
A kill-switch **reload no longer disarms the guard**: the `.nft` file uses `table` + `flush
|
||||
table`, which clears rules but leaves a named set's elements intact, so an Ansible run cannot
|
||||
empty the set mid-flight.
|
||||
|
||||
### Explicit, edge-independent fail-closed backstop (tunnel-down)
|
||||
Earlier the down-script flushed `@vpn_egress_gateway_src`, which **disarmed** the
|
||||
`ip saddr @vpn_egress_gateway_src oifname != tun0 drop` rule at exactly the moment the tunnel was
|
||||
@@ -67,12 +97,15 @@ guarantee that could leak if the edge router NATed that stale source. The down-s
|
||||
vpn-class packet that would leave the WAN while the tunnel is down is **explicitly dropped** in
|
||||
the host nft table — defense-in-depth, independent of edge-router behavior.
|
||||
|
||||
This is safe precisely because the **up-script flushes the set and re-adds the (possibly new)
|
||||
tunnel source on every reconnect** (`nft flush set …` then `nft add element …`), so a retained
|
||||
stale address self-corrects the instant the tunnel comes back. The set remains scoped to
|
||||
vpn-class only; node and internet-class traffic are never members, so **no invariant is
|
||||
weakened — only vpn-class is ever affected** (invariants 3 & 4 upheld). The routing blackhole in
|
||||
table `51820` remains as the second, independent fail-closed layer.
|
||||
The set is now **ADDITIVE** (2026-09-12): the up-script only ever `nft add element`s the live
|
||||
source. It used to `nft flush set` first, and that flush was itself a hole — between a reconnect
|
||||
and Cilium refreshing its cached egress IP, a packet still sourced from the PREVIOUS address
|
||||
matched neither the ip-rule (deleted by the down-script) nor the drop rule (the address had just
|
||||
been flushed out of the set). Additive closes it, `add element` is idempotent, and an extra
|
||||
element can only ever *tighten* the rule. The set stays scoped to tunnel sources; node and
|
||||
internet-class traffic are never members, so **no invariant is weakened** (invariants 3 & 4
|
||||
upheld). The routing blackhole in table `51820` remains as the second, independent fail-closed
|
||||
layer.
|
||||
|
||||
## Why the node default route is provably unaffected (design-level verification)
|
||||
- OpenVPN runs with `route-noexec` and `pull-filter ignore "redirect-gateway"` → it adds **zero**
|
||||
@@ -82,40 +115,79 @@ table `51820` remains as the second, independent fail-closed layer.
|
||||
ip-rules, never `table main` / `table default`.
|
||||
- The ip-rule matches **`from <tun0-addr>`** — an address the node itself never sources traffic
|
||||
from — so node-originated and internet-class packets never enter the tunnel table.
|
||||
- The nft table is **policy accept** and matches only `@vpn_egress_gateway_src` / the fwmark.
|
||||
- The nft table is **policy accept** and matches only `@vpn_egress_gateway_src`.
|
||||
- Live check (post-deploy): `ip route show table main default` still points at the WAN NIC, and
|
||||
`curl ipinfo` from the node shows the WAN IP, with the tunnel both up and forced down.
|
||||
Confirmed on `k3s-prod-3` 2026-09-12: `default via 192.168.2.1 dev eno1` with the tunnel up,
|
||||
node curl = the WAN IP, k3s `/readyz` = ok.
|
||||
|
||||
## Kill-switch layering & ESCALATION (honest limit)
|
||||
- The nft `@vpn_egress_gateway_src` rule and the routing blackhole robustly cover the case where the
|
||||
tunnel is up but a vpn-class packet is (mis)routed toward the WAN, and flaps.
|
||||
- The **tunnel fully-down** case relies on **Cilium Egress Gateway itself failing closed** when
|
||||
its egress interface `tun0` is absent (Cilium 1.16 drops egress-gateway-marked traffic with no
|
||||
valid egress path rather than falling back to the node's default route) **plus** the routing
|
||||
blackhole. That is the ADR's expected behavior.
|
||||
- For a host backstop that is **fully independent of Cilium even in a fail-open regression** when
|
||||
the tunnel is down (no tun0 address to key on), the host needs a stable marker on vpn-class
|
||||
packets. The only contract-available marker is the **`0x1c8` fwmark hook** — which the
|
||||
*Cilium-policy* side must stamp. **ESCALATION for the PM:** either (a) confirm the Cilium
|
||||
Egress Gateway 1.16 fail-closed + routing-blackhole guarantee is accepted as sufficient for
|
||||
R-NET-06, or (b) add the shared fwmark to the interface contract so the CiliumEgressGatewayPolicy
|
||||
marks vpn-class traffic and this role's fwmark rule becomes the tunnel-down-independent backstop.
|
||||
This is flagged rather than worked around — no spec was weakened to reach done.
|
||||
## Re-steering — Cilium does not follow tun0, and that is the whole ball game
|
||||
|
||||
Cilium resolves the policy's `interface: tun0` **once**, caching an (egress IP, egress ifindex)
|
||||
pair in its BPF egress map, and does not re-resolve when tun0 changes. Upstream says so: *"it
|
||||
does not automatically respond to a change in the gateway node's network configuration... You can
|
||||
force a fresh selection by re-applying the Egress Gateway policy."* Measured on `k3s-dev-3`:
|
||||
|
||||
| what | observed |
|
||||
|---|---|
|
||||
| after `systemctl restart vpn-egress-tunnel` | node tun0 at ifindex 1987, `cilium-dbg bpf egress list` still 1986 |
|
||||
| vpn-class pod egress in that state | times out; tcpdump shows **0** packets out the WAN and **0** out tun0 (no leak, no service) |
|
||||
| `egress-gateway-reconciliation-trigger-interval: 1s` | does **not** help — dev has that setting; still stale 20s+ later. It coalesces reconciliations that k8s object events already triggered; netlink link events are not among them |
|
||||
| stale egress **IP** with a live ifindex | does NOT leak either — the redirect follows the cached *ifindex*, so packets leave tun0 with the previous source (0 packets on the WAN, verified) |
|
||||
| touching any node label on the gateway | cached ifindex 1986 -> 1987 within 10s, egress recovers immediately |
|
||||
|
||||
So the failure mode is a **silent outage on every tunnel restart or reboot**, not a leak. The fix
|
||||
is `templates/vpn-egress-resteer.sh.j2`, which publishes `<tun0 addr>-<ifindex>` as the
|
||||
`egress-uplink` node label:
|
||||
|
||||
- **a node label, not an annotation on the policy** — the policy is Flux-managed GitOps and
|
||||
writing to it from a node invites drift/ownership churn; node labels are already this role's
|
||||
territory, and the label value doubles as the diagnostic (`kubectl get nodes -L
|
||||
homelab.keithjackson.dev/egress-uplink`);
|
||||
- **fired by the OpenVPN up-script** on every connect, via `systemctl start --no-block` so it can
|
||||
never block or fail the tunnel and does not need the kube-API to be up at that instant;
|
||||
- **plus `vpn-egress-resteer.timer`** (2 min) as the convergence backstop for the boot case where
|
||||
the API is not up yet and every in-script retry is exhausted;
|
||||
- **idempotent** — if the label already records the live uplink it exits without writing, so the
|
||||
timer is free to run often.
|
||||
|
||||
Proof it works: `systemctl stop` then `start` of the tunnel now recovers on its own (map
|
||||
1989 -> 1990, PIA IP returns). Before the fix the identical sequence stayed broken indefinitely —
|
||||
which also means the **recovery half of the `vpn-killswitch` conformance test could not have
|
||||
passed** on the pre-fix implementation.
|
||||
|
||||
## What requires a LIVE tunnel to validate (cannot be tested without operator creds)
|
||||
1. The PIA `.ovpn` + username/password actually authenticate and `tun0` comes up as a host iface.
|
||||
2. `egress-class`: a vpn-class pod's public IP is the PIA Stockholm IP; internet-class shows the WAN IP.
|
||||
3. `vpn-killswitch`: forcing `tun0`/the tunnel service down blackholes vpn-class egress (no leak)
|
||||
while internet-class is unaffected — the R-NET-06 fault-injection gate.
|
||||
4. That Cilium Egress Gateway SNAT to `tun0` traverses host `postrouting` so the nft set-scoped
|
||||
rules actually see the packets (eBPF datapath interaction — see escalation).
|
||||
4. ~~That Cilium Egress Gateway SNAT to `tun0` traverses host `postrouting` so the nft set-scoped
|
||||
rules actually see the packets (eBPF datapath interaction).~~ **ANSWERED 2026-09-12: it does
|
||||
NOT.** Counters proved the steered pod packets never enter netfilter at all. See "SCOPE,
|
||||
HONESTLY" above. The question is closed; the guard's claim is narrowed rather than pretended.
|
||||
5. MTU/PMTU across `pod → tun0 → PIA` (in-pod gluetun needed 1300 for Cilium VXLAN; on the node
|
||||
the tunnel rides the physical WAN, so `vpn_egress_gateway_tun_mtu` defaults empty — verify live).
|
||||
6. Port forwarding (PIA NAT-PMP) for qBittorrent — ADR-0015 Phase-6 follow-up, not in this role.
|
||||
|
||||
## Files
|
||||
- `defaults/main.yml` — all `vpn_egress_gateway_*` vars; the three vault-backed credential vars.
|
||||
- `tasks/main.yml` — packages, rt_tables, config/scripts/units, enable+start, node label.
|
||||
- `tasks/main.yml` — packages, rt_tables, config/scripts/units, enable+start, re-steer, node label
|
||||
(kill-switch -> tunnel -> re-steer -> **label last**, because the label is what arms steering).
|
||||
- `handlers/main.yml` — daemon-reload, kill-switch reload, tunnel restart.
|
||||
- `templates/` — wrapper `.conf`, credentials, up/down scripts, nft ruleset, both systemd units.
|
||||
- Playbook: `playbooks/vpn.egress.gateway.yml`. Vault keys: `inventory/group_vars/vpn_egress_gateway/README.md`.
|
||||
- `templates/` — wrapper `.conf`, credentials, up/down scripts, nft ruleset, the kill-switch and
|
||||
tunnel units, and `vpn-egress-resteer.{sh,service,timer}`.
|
||||
- Playbook: `playbooks/vpn.egress.gateway.yml`. Vault keys:
|
||||
`inventory/group_vars/vpn_egress_gateway/README.md` (dev) and
|
||||
`inventory/group_vars/vpn_egress_gateway_prod/README.md` (prod).
|
||||
|
||||
## Running it against prod
|
||||
```bash
|
||||
cd infra/ansible
|
||||
ansible-playbook playbooks/vpn.egress.gateway.yml \
|
||||
-e vpn_egress_gateway_hosts=vpn_egress_gateway_prod --limit k3s-prod-3 --list-hosts
|
||||
# confirm it says exactly `k3s-prod-3`, then drop --list-hosts
|
||||
```
|
||||
The target var is `vpn_egress_gateway_hosts`, **not** the cluster-wide `k3s_hosts`. Never apply
|
||||
the `egress-gateway` label by hand, and never run this role with tags that skip the tunnel or
|
||||
kill-switch tasks — the label is the last step precisely so nothing is steered at a node that is
|
||||
not ready to carry it.
|
||||
Reference in new issue
Block a user