Merge feature/vpn-egress-gateway-role into main (ADR-0015 host tunnel role, not yet deployed)
This commit is contained in:
15 files changed
+606
-88
No files matched your search
@@ -0,0 +1,52 @@
|
||||
# group_vars for the `vpn_egress_gateway` node(s) — ADR-0015 host tunnel secrets
|
||||
|
||||
The node-level VPN tunnel is **host infra**, so (per ADR-0015 / docs/07) its PIA credentials
|
||||
live **host-side in Ansible vault** here — NOT in a cluster SOPS Secret. Create an encrypted
|
||||
`vault.yml` in this directory (committed, encrypted; the passphrase file stays out of the repo,
|
||||
see `ansible.cfg` `vault_password_file`).
|
||||
|
||||
## Required vault keys (operator provides the VALUES — none are committed in plaintext)
|
||||
|
||||
```bash
|
||||
cd infra/ansible
|
||||
ansible-vault create inventory/group_vars/vpn_egress_gateway/vault.yml
|
||||
```
|
||||
|
||||
`vault.yml` must define exactly these three keys (the role maps them to its
|
||||
`vpn_egress_gateway_*` vars in `roles/vpn_egress_gateway/defaults/main.yml`):
|
||||
|
||||
| Vault key | What it is |
|
||||
|---|---|
|
||||
| `vault_vpn_egress_gateway_pia_username` | PIA account username (for the `.ovpn`'s `auth-user-pass`) |
|
||||
| `vault_vpn_egress_gateway_pia_password` | PIA account password |
|
||||
| `vault_vpn_egress_gateway_pia_ovpn` | The **full text** of the proven PIA `.ovpn` — the same `sweden-aes-128-cbc-udp-ip.ovpn` (PIA over OpenVPN-UDP, AES-128-CBC/SHA1/RSA-2048, IP-pinned Stockholm remote `158.173.241.x:1198/udp`) currently held in the k8s `pia-custom-ovpn` Secret. Use a YAML block scalar so the inline `<ca>`/`<crl-verify>` PEM survives verbatim. |
|
||||
|
||||
Example skeleton (values are placeholders — DO NOT commit unencrypted):
|
||||
|
||||
```yaml
|
||||
vault_vpn_egress_gateway_pia_username: "pXXXXXXX"
|
||||
vault_vpn_egress_gateway_pia_password: "REDACTED"
|
||||
vault_vpn_egress_gateway_pia_ovpn: |
|
||||
client
|
||||
dev tun
|
||||
proto udp
|
||||
remote 158.173.241.36 1198
|
||||
cipher aes-128-cbc
|
||||
auth sha1
|
||||
auth-user-pass
|
||||
...
|
||||
<ca>
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...
|
||||
-----END CERTIFICATE-----
|
||||
</ca>
|
||||
```
|
||||
|
||||
> To lift the exact `.ovpn` out of the existing cluster Secret:
|
||||
> `sops -d platform/vpn-egress/pia-custom-ovpn.sops.yaml` → the `sweden-aes-128-cbc-udp-ip.ovpn`
|
||||
> value is the text to paste under `vault_vpn_egress_gateway_pia_ovpn`.
|
||||
|
||||
## Notes
|
||||
- `vault.yml` is skipped by the yamllint gate (`ci/yamllint.yaml` ignores `**/vault.yml`).
|
||||
- This is the Ansible-bootstrap-layer secret (host credential), distinct from the platform's
|
||||
in-cluster SOPS + age secrets (ADR-0007) — different layer, different tool, by design (ADR-0015).
|
||||
@@ -20,3 +20,10 @@ k3s_secondary
|
||||
# (sudo) password comes from the vaulted group_vars/hypervisor/vault.yml.
|
||||
[hypervisor]
|
||||
localhost ansible_connection=local
|
||||
|
||||
# VPN egress-gateway node(s) — run the node-level split-tunnel OpenVPN + kill-switch
|
||||
# (ADR-0015, playbooks/vpn.egress.gateway.yml). Initially just k3s-dev-3 (single gateway;
|
||||
# add a second host here for Cilium Egress Gateway HA). Members are already defined above
|
||||
# with their ansible_host under k3s_secondary; this group only tags the egress role target.
|
||||
[vpn_egress_gateway]
|
||||
k3s-dev-3
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# Stand up the node-level VPN egress tunnel on the designated egress-gateway node(s)
|
||||
# (ADR-0015). Host infra — the split-tunnel OpenVPN service + policy routing + scoped
|
||||
# nftables kill-switch. The CiliumEgressGatewayPolicy that steers vpn-class pods to this
|
||||
# node's tun0 is authored separately as GitOps (platform/), against the same contract:
|
||||
# * inventory group `vpn_egress_gateway` (this play's target)
|
||||
# * k8s node label homelab.keithjackson.dev/egress-gateway=true (set by the role)
|
||||
# * host tunnel iface tun0 (split-tunnel; never the node default route)
|
||||
#
|
||||
# Secrets come from Ansible vault (inventory/group_vars/vpn_egress_gateway/vault.yml):
|
||||
# vault_vpn_egress_gateway_pia_username, vault_vpn_egress_gateway_pia_password, vault_vpn_egress_gateway_pia_ovpn
|
||||
# Run: ansible-playbook playbooks/vpn.egress.gateway.yml
|
||||
- name: Configure the node-level VPN egress gateway
|
||||
hosts: vpn_egress_gateway
|
||||
become: true
|
||||
gather_facts: true # package module + service facts need os facts
|
||||
roles:
|
||||
- role: vpn_egress_gateway
|
||||
@@ -0,0 +1,101 @@
|
||||
# role: vpn_egress_gateway (ADR-0015)
|
||||
|
||||
Stands up the **node-level VPN tunnel** on the egress-gateway node(s): a host **OpenVPN
|
||||
systemd service** (PIA over OpenVPN-UDP), **split-tunnel** via policy routing, and a
|
||||
**scoped nftables kill-switch**. This is the host half of ADR-0015; the
|
||||
`CiliumEgressGatewayPolicy` that steers `egress-class=vpn` pods to this node's `tun0` is
|
||||
separate GitOps authored against the shared **interface contract** below.
|
||||
|
||||
> **CONFORMANCE IS GATED ON A LIVE TUNNEL.** This role cannot be proven green without the
|
||||
> operator's PIA host credentials (see "What requires a live tunnel"). Authoring + lint pass
|
||||
> in CI; the `egress-class` / `vpn-killswitch` conformance tests (R-NET-05/06/07) go green only
|
||||
> 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`).
|
||||
- **k8s node label** `homelab.keithjackson.dev/egress-gateway=true` — set by this role; the
|
||||
`CiliumEgressGatewayPolicy` selects the gateway node by it.
|
||||
- **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.
|
||||
|
||||
## How the four ADR-0015 invariants are met
|
||||
|
||||
### 1 & 2 — split-tunnel; tun0 is never a default route
|
||||
`templates/vpn-egress.conf.j2` includes the operator's proven `.ovpn` and layers on
|
||||
`route-noexec` + `pull-filter ignore "redirect-gateway"`, so **OpenVPN installs no routes and
|
||||
never touches the main routing table**. The node's own default route stays on the WAN whether
|
||||
the tunnel is up or down — k3s/etcd/kubelet are unaffected.
|
||||
|
||||
Steering is done with **policy routing** in a dedicated table (`51820`, named `vpn_egress`):
|
||||
- The OpenVPN **up-script** adds `default dev tun0` to table `51820` and an ip-rule
|
||||
**`from <tun0-addr> lookup 51820`** (priority 5000). Only packets whose source is the tunnel
|
||||
address — i.e. the vpn-class pods Cilium has SNAT'd to `tun0` — consult that table. Nothing
|
||||
else does, so nothing else is re-routed.
|
||||
- The **down-script** removes the ip-rule and the tunnel default (leaving the blackhole).
|
||||
|
||||
### 3 & 4 — kill-switch scoped to vpn-class, opposite fail directions
|
||||
Two independent layers, both scoped so they can only ever affect vpn-class traffic:
|
||||
|
||||
1. **Routing blackhole (fail-closed fallback).** Table `51820` always carries a permanent
|
||||
`blackhole default` (installed by `vpn-egress-killswitch.service`, metric 4000). When the
|
||||
tunnel default is withdrawn (flap / partial teardown) while the ip-rule still steers, packets
|
||||
hit the blackhole instead of falling through to the WAN.
|
||||
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).
|
||||
- `meta mark 0x1c8 oifname != tun0 drop` — the optional fwmark backstop.
|
||||
|
||||
`@vpn_egress_gateway_src` holds the **live tun0 address** (added by the up-script, flushed by the
|
||||
down-script). Node traffic and `internet`-class traffic are never in the set, never carry the
|
||||
mark, 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.
|
||||
|
||||
## 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**
|
||||
routes and cannot install a `0.0.0.0/0` via `tun0`. (This is exactly the failure mode that made
|
||||
`hostNetwork` gluetun non-viable in ADR-0015; here OpenVPN is explicitly told not to route.)
|
||||
- All tunnel routing lives in **table 51820 only**; the up/down scripts touch that table and
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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).
|
||||
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.
|
||||
- `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`.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
# vpn_egress_gateway role defaults (ADR-0015).
|
||||
# Node-level split-tunnel host OpenVPN + policy routing + scoped nftables kill-switch.
|
||||
# All vars are role-name prefixed. NO secret VALUES live here — the three credential
|
||||
# vars below reference operator-provided Ansible-vault keys (see the role README and
|
||||
# inventory/group_vars/vpn_egress_gateway/README.md). They are intentionally NOT
|
||||
# defaulted so a missing vault fails loud rather than silently deploying a broken tunnel.
|
||||
|
||||
# --- packages -------------------------------------------------------------------
|
||||
vpn_egress_gateway_packages:
|
||||
- openvpn
|
||||
- nftables
|
||||
|
||||
# --- credentials / config (from Ansible vault — operator provides the values) ----
|
||||
# The PIA .ovpn PROVEN in Phase 3 (ADR-0013/0015): sweden-aes-128-cbc-udp-ip.ovpn —
|
||||
# PIA over OpenVPN-UDP, AES-128-CBC / SHA1 / RSA-2048, IP-pinned PIA Stockholm remote
|
||||
# (158.173.241.x:1198/udp), auth-user-pass. Same file that drove the working gluetun pod.
|
||||
vpn_egress_gateway_pia_username: "{{ vault_vpn_egress_gateway_pia_username }}"
|
||||
vpn_egress_gateway_pia_password: "{{ vault_vpn_egress_gateway_pia_password }}"
|
||||
# The FULL text of the PIA .ovpn (inline <ca>/<crl-verify>, cipher/auth, remote line).
|
||||
vpn_egress_gateway_pia_ovpn: "{{ vault_vpn_egress_gateway_pia_ovpn }}"
|
||||
|
||||
# --- on-disk layout -------------------------------------------------------------
|
||||
vpn_egress_gateway_config_dir: /etc/openvpn/client
|
||||
vpn_egress_gateway_scripts_dir: /etc/openvpn/scripts
|
||||
vpn_egress_gateway_ovpn_path: "{{ vpn_egress_gateway_config_dir }}/pia.ovpn"
|
||||
vpn_egress_gateway_credentials_path: "{{ vpn_egress_gateway_config_dir }}/pia-credentials.txt"
|
||||
vpn_egress_gateway_wrapper_conf: "{{ vpn_egress_gateway_config_dir }}/vpn-egress.conf"
|
||||
|
||||
# --- tunnel interface + split-tunnel policy routing (invariants 1 & 2) ----------
|
||||
# The host tunnel interface Cilium Egress Gateway steers vpn-class pods out of.
|
||||
vpn_egress_gateway_tun_iface: tun0
|
||||
# Dedicated policy-routing table for the tunnel (NEVER the main table — the node's
|
||||
# own default route stays on the WAN). Numeric id + a name added to rt_tables.
|
||||
vpn_egress_gateway_route_table_id: 51820
|
||||
vpn_egress_gateway_route_table_name: vpn_egress
|
||||
# ip-rule priority for the `from <tun0-ip> lookup vpn_egress` rule (well below main).
|
||||
vpn_egress_gateway_rule_priority: 5000
|
||||
# Route metrics inside the dedicated table: the live tunnel default is preferred;
|
||||
# a permanent blackhole is the fail-closed fallback during flaps / partial teardown.
|
||||
vpn_egress_gateway_tun_route_metric: 50
|
||||
vpn_egress_gateway_blackhole_metric: 4000
|
||||
|
||||
# Optional tun MTU override. Empty => honor whatever the .ovpn/PIA negotiates. On the
|
||||
# NODE the tunnel rides the physical WAN (1500), so unlike the in-pod gluetun case
|
||||
# (Cilium VXLAN 1450 → needed 1300) no shrink is required by default.
|
||||
vpn_egress_gateway_tun_mtu: ""
|
||||
|
||||
# --- nftables kill-switch (invariants 3 & 4 — scoped to vpn-class ONLY) ----------
|
||||
vpn_egress_gateway_nft_table: vpn_egress_gateway_killswitch
|
||||
# Named set holding the LIVE tun0 source address (maintained by the up/down scripts).
|
||||
# The kill-switch matches ONLY packets SNAT'd to this address by Cilium Egress Gateway
|
||||
# — i.e. exactly the egress-class=vpn traffic. Node / internet-class traffic is never
|
||||
# in this set and is never matched (fails OPEN toward the WAN).
|
||||
vpn_egress_gateway_nft_src_set: vpn_egress_gateway_src
|
||||
# Contract-hook fwmark: if the CiliumEgressGatewayPolicy side stamps vpn-class packets
|
||||
# with this mark, the kill-switch also drops marked packets not leaving via tun0 — an
|
||||
# extra, tunnel-down-independent backstop. See README "Kill-switch layering / escalation".
|
||||
vpn_egress_gateway_fwmark: "0x1c8"
|
||||
|
||||
# --- kubernetes node label (steering contract) ----------------------------------
|
||||
# The label the parallel CiliumEgressGatewayPolicy selects the gateway node by.
|
||||
vpn_egress_gateway_node_label_key: homelab.keithjackson.dev/egress-gateway
|
||||
vpn_egress_gateway_node_label_value: "true"
|
||||
# k3s ships its kubeconfig here; the gateway node is a k3s server so `k3s kubectl` works.
|
||||
vpn_egress_gateway_kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd_service:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Reload vpn-egress kill-switch
|
||||
ansible.builtin.systemd_service:
|
||||
name: vpn-egress-killswitch.service
|
||||
state: restarted
|
||||
|
||||
- name: Restart vpn-egress tunnel
|
||||
ansible.builtin.systemd_service:
|
||||
name: vpn-egress-tunnel.service
|
||||
state: restarted
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
# vpn_egress_gateway — node-level split-tunnel PIA OpenVPN + policy routing + scoped
|
||||
# nftables kill-switch on the egress-gateway node(s) (ADR-0015). Host infra, Ansible-
|
||||
# managed like k3s itself; the CiliumEgressGatewayPolicy that steers pods is separate GitOps.
|
||||
|
||||
- name: Install OpenVPN and nftables
|
||||
ansible.builtin.package:
|
||||
name: "{{ vpn_egress_gateway_packages }}"
|
||||
state: present
|
||||
|
||||
- name: Register the dedicated policy-routing table name
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/iproute2/rt_tables
|
||||
line: "{{ vpn_egress_gateway_route_table_id }} {{ vpn_egress_gateway_route_table_name }}"
|
||||
regexp: "^{{ vpn_egress_gateway_route_table_id }}\\s"
|
||||
state: present
|
||||
create: true
|
||||
mode: "0644"
|
||||
|
||||
- name: Ensure the OpenVPN config and scripts directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
loop:
|
||||
- "{{ vpn_egress_gateway_config_dir }}"
|
||||
- "{{ vpn_egress_gateway_scripts_dir }}"
|
||||
|
||||
- name: Deploy the operator's proven PIA .ovpn (from Ansible vault)
|
||||
ansible.builtin.copy:
|
||||
content: "{{ vpn_egress_gateway_pia_ovpn }}"
|
||||
dest: "{{ vpn_egress_gateway_ovpn_path }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
notify: Restart vpn-egress tunnel
|
||||
|
||||
- name: Deploy the PIA auth-user-pass credentials (from Ansible vault)
|
||||
ansible.builtin.template:
|
||||
src: pia-credentials.txt.j2
|
||||
dest: "{{ vpn_egress_gateway_credentials_path }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
notify: Restart vpn-egress tunnel
|
||||
|
||||
- name: Deploy the split-tunnel OpenVPN wrapper config
|
||||
ansible.builtin.template:
|
||||
src: vpn-egress.conf.j2
|
||||
dest: "{{ vpn_egress_gateway_wrapper_conf }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
notify: Restart vpn-egress tunnel
|
||||
|
||||
- name: Deploy the split-tunnel up/down policy-routing scripts
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "{{ vpn_egress_gateway_scripts_dir }}/{{ item }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
loop:
|
||||
- vpn-egress-up.sh
|
||||
- vpn-egress-down.sh
|
||||
notify: Restart vpn-egress tunnel
|
||||
|
||||
- name: Deploy the scoped nftables kill-switch ruleset
|
||||
ansible.builtin.template:
|
||||
src: vpn-egress-killswitch.nft.j2
|
||||
dest: "{{ vpn_egress_gateway_config_dir }}/vpn-egress-killswitch.nft"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
notify: Reload vpn-egress kill-switch
|
||||
|
||||
- name: Deploy the kill-switch systemd unit
|
||||
ansible.builtin.template:
|
||||
src: vpn-egress-killswitch.service.j2
|
||||
dest: /etc/systemd/system/vpn-egress-killswitch.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Reload vpn-egress kill-switch
|
||||
|
||||
- name: Deploy the tunnel systemd unit
|
||||
ansible.builtin.template:
|
||||
src: vpn-egress-tunnel.service.j2
|
||||
dest: /etc/systemd/system/vpn-egress-tunnel.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Restart vpn-egress tunnel
|
||||
|
||||
- name: Flush handlers so units/routing exist before enable+start
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Enable and start the kill-switch (must be up before the tunnel)
|
||||
ansible.builtin.systemd_service:
|
||||
name: vpn-egress-killswitch.service
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Enable and start the split-tunnel VPN service
|
||||
ansible.builtin.systemd_service:
|
||||
name: vpn-egress-tunnel.service
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# --- steering contract: label the k8s node the Cilium Egress Gateway policy selects ----
|
||||
- name: Read the current egress-gateway node label
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
k3s kubectl get node {{ inventory_hostname }}
|
||||
-o jsonpath={.metadata.labels.{{ vpn_egress_gateway_node_label_key | replace('.', '\\.') | replace('/', '\\/') }}}
|
||||
environment:
|
||||
KUBECONFIG: "{{ vpn_egress_gateway_kubeconfig }}"
|
||||
register: vpn_egress_gateway_label_current
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Label this node as the VPN egress gateway
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
k3s kubectl label node {{ inventory_hostname }}
|
||||
{{ vpn_egress_gateway_node_label_key }}={{ vpn_egress_gateway_node_label_value }} --overwrite
|
||||
environment:
|
||||
KUBECONFIG: "{{ vpn_egress_gateway_kubeconfig }}"
|
||||
register: vpn_egress_gateway_label_apply
|
||||
changed_when: "'not labeled' not in vpn_egress_gateway_label_apply.stdout"
|
||||
when: vpn_egress_gateway_label_current.stdout | default('') != vpn_egress_gateway_node_label_value
|
||||
@@ -0,0 +1,2 @@
|
||||
{{ vpn_egress_gateway_pia_username }}
|
||||
{{ vpn_egress_gateway_pia_password }}
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# {{ ansible_managed }}
|
||||
# OpenVPN --down hook (with `down-pre`, runs BEFORE tun0 is destroyed). Tears down the
|
||||
# split-tunnel steering so vpn-class traffic FAILS CLOSED, never leaks to the WAN
|
||||
# (ADR-0015 invariants 3 & 4).
|
||||
#
|
||||
# Order matters: (1) empty the kill-switch source set and drop the ip-rule so no packet
|
||||
# can be routed toward a vanishing tun0, then (2) remove the tunnel default from the
|
||||
# dedicated table. The permanent `blackhole default` in that table is LEFT in place as
|
||||
# the fail-closed fallback. The node's main-table WAN default is never touched here.
|
||||
set -uo pipefail
|
||||
|
||||
TABLE_ID="{{ vpn_egress_gateway_route_table_id }}"
|
||||
RULE_PRIO="{{ vpn_egress_gateway_rule_priority }}"
|
||||
NFT_TABLE="{{ vpn_egress_gateway_nft_table }}"
|
||||
NFT_SET="{{ vpn_egress_gateway_nft_src_set }}"
|
||||
IFACE="${dev:-{{ vpn_egress_gateway_tun_iface }}}"
|
||||
SRC="${ifconfig_local:-}"
|
||||
|
||||
# 1) Stop scoping/steering to the (now dead) tunnel source.
|
||||
nft flush set inet "${NFT_TABLE}" "${NFT_SET}" 2>/dev/null || true
|
||||
if [ -n "${SRC}" ]; then
|
||||
while ip rule del from "${SRC}" lookup "${TABLE_ID}" priority "${RULE_PRIO}" 2>/dev/null; do :; done
|
||||
fi
|
||||
|
||||
# 2) Remove the tunnel default from the dedicated table; keep the blackhole fallback.
|
||||
ip route del default dev "${IFACE}" table "${TABLE_ID}" 2>/dev/null || true
|
||||
|
||||
echo "vpn-egress-down: steering removed; vpn-class fails closed (blackhole retained)"
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/sbin/nft -f
|
||||
# {{ ansible_managed }}
|
||||
# ADR-0015 kill-switch (invariants 3 & 4) — SCOPED TO vpn-class ONLY, fails CLOSED.
|
||||
#
|
||||
# Own dedicated table so re-loading it never disturbs Cilium's or the node's own rules.
|
||||
# The base chains are policy ACCEPT: this table can ONLY ever drop the narrow vpn-class
|
||||
# traffic matched below; node traffic and internet-class traffic are untouched and keep
|
||||
# egressing the WAN (fail OPEN — invariant 4). It matches on:
|
||||
# * @{{ vpn_egress_gateway_nft_src_set }} — the LIVE tun0 address Cilium Egress Gateway SNATs
|
||||
# vpn-class pods to (populated by the OpenVPN up-script; emptied by the down-script).
|
||||
# This IS the egress-class=vpn traffic and nothing else.
|
||||
# * meta mark {{ vpn_egress_gateway_fwmark }} — optional contract-hook: a tunnel-DOWN-independent
|
||||
# backstop, active only if the CiliumEgressGatewayPolicy side stamps this fwmark.
|
||||
|
||||
table inet {{ vpn_egress_gateway_nft_table }} {
|
||||
# Live tunnel source address(es). Empty whenever the tunnel is down => the two
|
||||
# saddr rules below match nothing => vpn-class has no path at all (fail closed).
|
||||
set {{ vpn_egress_gateway_nft_src_set }} {
|
||||
type ipv4_addr
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
# oifname is resolved at postrouting; SNAT/masquerade has already applied here,
|
||||
# so the Cilium-SNAT'd vpn-class packet's saddr is the tunnel address.
|
||||
type filter hook postrouting priority 100; policy accept;
|
||||
|
||||
# Tunnel UP: let the steered vpn-class traffic out tun0.
|
||||
oifname "{{ vpn_egress_gateway_tun_iface }}" ip saddr @{{ vpn_egress_gateway_nft_src_set }} accept
|
||||
|
||||
# FAIL CLOSED: a vpn-class (tunnel-sourced) packet trying to leave ANY other
|
||||
# interface (i.e. the WAN) is dropped — never leaks. Scoped by the set, so it
|
||||
# can never match node or internet-class traffic.
|
||||
ip saddr @{{ vpn_egress_gateway_nft_src_set }} oifname != "{{ vpn_egress_gateway_tun_iface }}" drop
|
||||
|
||||
# Tunnel-down-independent backstop (contract hook, see role README): if vpn-class
|
||||
# packets carry the agreed fwmark, drop any not leaving via tun0 even when the set
|
||||
# is empty (tunnel fully down). Harmless if the Cilium side does not set the mark.
|
||||
meta mark {{ vpn_egress_gateway_fwmark }} oifname != "{{ vpn_egress_gateway_tun_iface }}" drop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# {{ ansible_managed }}
|
||||
# Installs the ADR-0015 kill-switch state that must exist INDEPENDENTLY of (and BEFORE)
|
||||
# the tunnel: the scoped nftables table/set and the permanent fail-closed blackhole route
|
||||
# in the dedicated policy-routing table. Ordered before the tunnel so the nft set exists
|
||||
# when the OpenVPN up-script registers the live tunnel address.
|
||||
[Unit]
|
||||
Description=VPN egress gateway kill-switch (nftables + fail-closed blackhole) — ADR-0015
|
||||
Documentation=file://{{ vpn_egress_gateway_config_dir }}/README
|
||||
After=network-pre.target nftables.service
|
||||
Wants=network-pre.target
|
||||
Before=vpn-egress-tunnel.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
# Load the scoped kill-switch table (idempotent: delete-then-add).
|
||||
ExecStart=/usr/sbin/nft -f {{ vpn_egress_gateway_config_dir }}/vpn-egress-killswitch.nft
|
||||
# Ensure the dedicated table has a permanent blackhole default (fail closed) even before
|
||||
# the tunnel first connects. The tunnel up-script adds the preferred `default dev tun0`.
|
||||
ExecStart=/sbin/ip route replace blackhole default table {{ vpn_egress_gateway_route_table_id }} metric {{ vpn_egress_gateway_blackhole_metric }}
|
||||
# Tear down cleanly: remove the whole scoped table (never touches other nft rules).
|
||||
ExecStop=/usr/sbin/nft delete table inet {{ vpn_egress_gateway_nft_table }}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,25 @@
|
||||
# {{ ansible_managed }}
|
||||
# Node-level host OpenVPN tunnel (ADR-0015): PIA over OpenVPN-UDP, split-tunnel. Creates
|
||||
# {{ vpn_egress_gateway_tun_iface }} but — via the wrapper's route-noexec / pull-filter — NEVER sets
|
||||
# the node default route. The node (k3s/etcd/kubelet) keeps working whether this is up or down.
|
||||
[Unit]
|
||||
Description=VPN egress gateway tunnel (PIA OpenVPN-UDP, split-tunnel) — ADR-0015
|
||||
Documentation=file://{{ vpn_egress_gateway_config_dir }}/README
|
||||
After=network-online.target vpn-egress-killswitch.service
|
||||
Wants=network-online.target
|
||||
Requires=vpn-egress-killswitch.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
# openvpn signals readiness to systemd once the tunnel is up.
|
||||
ExecStart=/usr/sbin/openvpn --suppress-timestamps --nobind --config {{ vpn_egress_gateway_wrapper_conf }}
|
||||
# The up/down scripts need CAP_NET_ADMIN to manage tun0, ip rules/routes and the nft set.
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_SETPCAP CAP_DAC_OVERRIDE
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Not the node default route, so a tunnel crash must NOT cascade to the node — just retry.
|
||||
KillMode=process
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# {{ ansible_managed }}
|
||||
# OpenVPN --up hook. Runs as root AFTER tun0 is created, with the tunnel's dynamic
|
||||
# address available in $ifconfig_local (and $dev = the interface, forced to tun0).
|
||||
#
|
||||
# Installs the SPLIT-TUNNEL policy routing (ADR-0015 invariant 2): only traffic whose
|
||||
# source is the tunnel address (i.e. the vpn-class pods Cilium Egress Gateway SNATs to
|
||||
# tun0) is routed via the dedicated table out tun0. The node's own default route in the
|
||||
# MAIN table is never touched.
|
||||
set -euo pipefail
|
||||
|
||||
TABLE_ID="{{ vpn_egress_gateway_route_table_id }}"
|
||||
RULE_PRIO="{{ vpn_egress_gateway_rule_priority }}"
|
||||
TUN_METRIC="{{ vpn_egress_gateway_tun_route_metric }}"
|
||||
BH_METRIC="{{ vpn_egress_gateway_blackhole_metric }}"
|
||||
NFT_TABLE="{{ vpn_egress_gateway_nft_table }}"
|
||||
NFT_SET="{{ vpn_egress_gateway_nft_src_set }}"
|
||||
IFACE="${dev:-{{ vpn_egress_gateway_tun_iface }}}"
|
||||
SRC="${ifconfig_local:-}"
|
||||
|
||||
if [ -z "${SRC}" ]; then
|
||||
echo "vpn-egress-up: ifconfig_local not set; refusing to install partial routing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Preferred default via the tunnel inside the DEDICATED table (not the main table).
|
||||
ip route replace default dev "${IFACE}" table "${TABLE_ID}" metric "${TUN_METRIC}"
|
||||
# Permanent fail-closed fallback: if the tunnel default is ever withdrawn while the
|
||||
# ip-rule still steers traffic here (flap / partial teardown), packets blackhole
|
||||
# instead of falling through to the main table's WAN default (invariant 4: fail closed).
|
||||
ip route replace blackhole default table "${TABLE_ID}" metric "${BH_METRIC}"
|
||||
|
||||
# Steer ONLY tunnel-sourced (Cilium-SNAT'd vpn-class) packets into the dedicated table.
|
||||
# Idempotent: delete any stale copy of this exact rule first.
|
||||
while ip rule del from "${SRC}" lookup "${TABLE_ID}" priority "${RULE_PRIO}" 2>/dev/null; do :; done
|
||||
ip rule add from "${SRC}" lookup "${TABLE_ID}" priority "${RULE_PRIO}"
|
||||
|
||||
# Register the live tunnel source with the kill-switch set so the nft backstop can scope
|
||||
# to exactly this traffic. Flush any stale members first (address changes on reconnect).
|
||||
nft flush set inet "${NFT_TABLE}" "${NFT_SET}" 2>/dev/null || true
|
||||
nft add element inet "${NFT_TABLE}" "${NFT_SET}" "{ ${SRC} }"
|
||||
|
||||
echo "vpn-egress-up: tun=${IFACE} src=${SRC} table=${TABLE_ID} — split-tunnel routing active"
|
||||
@@ -0,0 +1,45 @@
|
||||
# {{ ansible_managed }}
|
||||
# Split-tunnel wrapper for the node-level PIA egress tunnel (ADR-0015, invariants 1 & 2).
|
||||
# It INCLUDES the operator's proven PIA .ovpn and layers the split-tunnel directives on top,
|
||||
# so the same file that drove the working gluetun pod is reused verbatim (only the packaging
|
||||
# changes: pod -> host systemd service).
|
||||
|
||||
# Pull in the operator's PIA .ovpn (inline <ca>/<crl-verify>, cipher aes-128-cbc, auth sha1,
|
||||
# remote 158.173.241.x 1198 udp, auth-user-pass). Directives AFTER this include augment/override it.
|
||||
config {{ vpn_egress_gateway_ovpn_path }}
|
||||
|
||||
# Force a deterministic interface name so the Cilium Egress Gateway policy + kill-switch can
|
||||
# reference it by name. (The .ovpn's own `dev tun` is overridden here.)
|
||||
dev {{ vpn_egress_gateway_tun_iface }}
|
||||
dev-type tun
|
||||
|
||||
# --- SPLIT-TUNNEL: create tun0 but NEVER become the node default route (invariant 2) ---------
|
||||
# route-noexec: OpenVPN installs NO routes itself (no `redirect-gateway`, no pushed routes).
|
||||
route-noexec
|
||||
# Belt-and-suspenders: even if the server pushes redirect-gateway, drop it before it is applied.
|
||||
pull-filter ignore "redirect-gateway"
|
||||
# Do not let the VPN rewrite the NODE's DNS — the node/kubelet keep the local resolver.
|
||||
pull-filter ignore "dhcp-option DNS"
|
||||
pull-filter ignore "dhcp-option DOMAIN"
|
||||
|
||||
# Credentials for the .ovpn's `auth-user-pass` (rendered from Ansible vault, mode 0600).
|
||||
auth-user-pass {{ vpn_egress_gateway_credentials_path }}
|
||||
auth-nocache
|
||||
|
||||
# The dedicated policy-routing table + ip-rule + nft set membership are installed by the
|
||||
# up/down scripts (they receive tun0's dynamic address + gateway from OpenVPN's env).
|
||||
script-security 2
|
||||
up {{ vpn_egress_gateway_scripts_dir }}/vpn-egress-up.sh
|
||||
down {{ vpn_egress_gateway_scripts_dir }}/vpn-egress-down.sh
|
||||
# Run the down script BEFORE tearing the tunnel down so it can clean up while tun0 still exists.
|
||||
down-pre
|
||||
persist-tun
|
||||
{% if vpn_egress_gateway_tun_mtu | length > 0 %}
|
||||
tun-mtu {{ vpn_egress_gateway_tun_mtu }}
|
||||
{% endif %}
|
||||
|
||||
# Keep the tunnel resilient; systemd also restarts the unit on hard failure.
|
||||
keepalive 10 60
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
verb 3
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
# ============================================================================
|
||||
# Cilium Egress Gateway steering for `egress-class=vpn` pods — ADR-0015.
|
||||
# Serves: R-NET-05, R-NET-06, R-NET-07.
|
||||
#
|
||||
# ##########################################################################
|
||||
# ## NOT WIRED INTO THE RUNTIME YET — DO NOT ADD TO kustomization.yaml ##
|
||||
# ## UNTIL THE HOST tun0 TUNNEL IS LIVE AND THIS DATAPATH IS VALIDATED. ##
|
||||
# ##########################################################################
|
||||
#
|
||||
# This policy steers vpn-class pods out a gateway node's host `tun0` interface. It is INERT and
|
||||
# potentially disruptive until the Ansible-managed host OpenVPN split-tunnel service actually exists
|
||||
# on a node labeled `homelab.keithjackson.dev/egress-gateway=true`. If Flux shipped this before the
|
||||
# tunnel is up, vpn-class pods would be SNAT'd to a non-existent interface — an unvalidated datapath.
|
||||
# Per platform/vpn-egress/README.md: "the steering datapath is intentionally NOT committed [to the
|
||||
# runtime kustomization] until the mechanism is chosen + validated." ADR-0015 chose the mechanism
|
||||
# (node tunnel + Cilium Egress Gateway); this file IS that datapath, authored but held out of the
|
||||
# resource list for the PM to enable after a live-tunnel validation (see TODO at the bottom).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# PREREQUISITES CONFIRMED (2026-08-24):
|
||||
# * Cilium Egress Gateway is ENABLED per ADR-0002 — platform/cilium/helmrelease.yaml and
|
||||
# platform/cilium/values.yaml both set (all [parity] with the Ansible bootstrap render):
|
||||
# egressGateway.enabled: true
|
||||
# bpf.masquerade: true (Egress Gateway REQUIRES eBPF masquerade)
|
||||
# kubeProxyReplacement: true
|
||||
# No Cilium HelmRelease change is needed for this policy. (If any of the three were false this
|
||||
# would be an ESCALATION, not a silent HelmRelease edit — they are all true, so we proceed.)
|
||||
#
|
||||
# INTERFACE CONTRACT (shared verbatim with the parallel Ansible host-tunnel role — ADR-0015):
|
||||
# * Gateway node label ...... homelab.keithjackson.dev/egress-gateway=true (applied by Ansible)
|
||||
# * Host tunnel interface .... tun0 (split-tunnel OpenVPN)
|
||||
# * Client selection ......... homelab.keithjackson.dev/egress-class=vpn (Kyverno-enforced)
|
||||
# ---------------------------------------------------------------------------
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumEgressGatewayPolicy
|
||||
metadata:
|
||||
name: vpn-egress-steering
|
||||
annotations:
|
||||
homelab.keithjackson.dev/requirements: R-NET-05,R-NET-06,R-NET-07
|
||||
homelab.keithjackson.dev/adr: ADR-0015
|
||||
spec:
|
||||
# --- Client selection (INVARIANTS 1 & 2) --------------------------------
|
||||
# ONLY pods explicitly opted into the vpn class are matched. Default/internet-class pods and node
|
||||
# traffic are never selected, so they stay on the local/private-WAN path (invariant 1: default is
|
||||
# the local path). Because ONLY these flows are steered, tun0 never becomes a default route for the
|
||||
# node or for any other pod (invariant 2). The selector is intentionally cluster-wide (no namespace
|
||||
# scope) so the class label alone is the contract — every namespace's vpn-class pods route here.
|
||||
selectors:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
homelab.keithjackson.dev/egress-class: vpn
|
||||
# --- What gets tunneled (INVARIANT 1: only PUBLIC internet leaves via the VPN) ------------------
|
||||
# destinationCIDRs is the full internet; excludedCIDRs carves out everything that must stay LOCAL:
|
||||
# all RFC1918 private ranges (which also cover the Cilium cluster-pool pod CIDR 10.0.0.0/8, the k3s
|
||||
# service/pod ranges, and the LAN 192.168.x/10.73.x node network). So intra-cluster east-west,
|
||||
# pod->service, and LAN traffic are NOT SNAT'd out tun0 — only genuinely public destinations are.
|
||||
# Excluded flows fall through to Cilium's normal local datapath (invariant 1).
|
||||
destinationCIDRs:
|
||||
- "0.0.0.0/0"
|
||||
excludedCIDRs:
|
||||
- "10.0.0.0/8" # RFC1918 + Cilium cluster-pool pod CIDR (CLUSTER_POD_CIDR) + k3s service net
|
||||
- "172.16.0.0/12" # RFC1918
|
||||
- "192.168.0.0/16" # RFC1918 (home LAN / node network family)
|
||||
# --- Where it egresses --------------------------------------------------
|
||||
# The gateway node (Ansible-labeled) SNATs matched traffic out its host split-tunnel `tun0`. When
|
||||
# tun0 is absent/down, Cilium has no egress interface here; the host nftables kill-switch (Ansible,
|
||||
# ADR-0015) is the authoritative fail-closed backstop that DROPS this vpn-class traffic — scoped to
|
||||
# vpn-class only, so default/internet-class is unaffected (invariants 3 & 4: vpn fails CLOSED,
|
||||
# internet fails OPEN).
|
||||
egressGateway:
|
||||
nodeSelector:
|
||||
matchLabels:
|
||||
homelab.keithjackson.dev/egress-gateway: "true"
|
||||
interface: tun0
|
||||
# ---------------------------------------------------------------------------
|
||||
# TODO(PM — enable after live validation, then retire Gluetun):
|
||||
# 1. Confirm the Ansible host-tunnel role has brought `tun0` up on a node labeled
|
||||
# homelab.keithjackson.dev/egress-gateway=true, and the host nftables kill-switch is in place.
|
||||
# 2. Add `- egress-steering.yaml` to platform/vpn-egress/kustomization.yaml so Flux ships it.
|
||||
# 3. Run the `egress-class` conformance test: vpn-class pod shows the PIA IP, internet-class shows
|
||||
# the WAN IP, and a git label flip re-routes (R-NET-05/07).
|
||||
# 4. Run the `vpn-killswitch` conformance test: force tun0 down => vpn-class blackholed (no leak),
|
||||
# internet-class unaffected (R-NET-06).
|
||||
# 5. Only AFTER both pass green: retire the Gluetun Deployment (remove gluetun-gateway.yaml from
|
||||
# kustomization.yaml) per ADR-0015 ("Gluetun is dropped as the gateway"). Do NOT retire it in
|
||||
# the same change that enables this — keep the validated fallback until steering is proven.
|
||||
# ---------------------------------------------------------------------------
|
||||
Reference in new issue
Block a user