This one shares literal ancestry with the lenny cluster: it started as the exact same stripped-down, single-node RKE2 test definition — same base image, same kernel-arg fixes for KVM console visibility, same growfs-on-first-boot script. Then Kubernetes came out entirely, and what was left got turned into a two-NIC nftables gateway/firewall appliance instead.
Kubernetes Removed, Not Just Unused
This wasn't leaving RKE2 installed but empty — the whole Kubernetes section is gone from
the definition, along with everything that only existed to support it: the RKE2 server/agent
configs, the CNI-specific tmpfs script, an unused GPG key, and the shell aliases in
profile.local (kubectl shortcuts replaced with nft/
conntrack ones). The one script that survived untouched is the growfs fix —
it applies regardless of what's actually running on top.
One NIC Becomes Two
The single static-IP interface from the test image became a proper WAN/LAN split: DHCP upstream, the appliance itself as the gateway on the protected side.
| Interface | Role | Config |
|---|---|---|
enp1s0 | WAN | DHCP (upstream/ISP-facing) |
enp2s0 | LAN | Static 192.168.100.1/24, appliance is the gateway |
The Ruleset
A deliberately small, readable base: drop by default, accept established/related connections statefully, allow only what's explicitly intended.
chain forward {
type filter hook forward priority 0; policy drop;
ct state invalid drop
ct state established,related accept
# LAN clients may reach WAN; nothing initiates from WAN to LAN
iifname $LAN_IF oifname $WAN_IF accept
}
table ip nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname $WAN_IF masquerade
}
}
Three things do all the real work: policy drop means nothing gets through by
accident; ct state established,related accept means return traffic for connections
LAN clients actually opened just works; and the single iifname $LAN_IF oifname $WAN_IF
accept line is the entire “LAN can reach the internet, nothing from the internet can
reach LAN” policy — there's no matching WAN→LAN rule, so the default handles that
direction with nothing extra needed.
Making It Actually Route
nftables handles filtering, but the kernel still needs telling it's allowed to forward packets between interfaces at all:
net.ipv4.ip_forward = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.log_martians = 1
The hardening lines earn their place on a box sitting at a network boundary: no ICMP redirects silently repointing routes, strict reverse-path filtering so packets claiming to be from LAN can't sneak in the WAN interface, and martian packets get logged instead of silently dropped.
Testing Needed Two Networks, Not One
The test harness went from one libvirt NAT network to two: a WAN network NAT'd to the host standing in for the upstream ISP link, and an isolated LAN bridge with no libvirt-side DHCP of its own — the appliance's static address is the only thing on that segment, exactly like real hardware.
Nothing here is exotic — a small nftables ruleset and the sysctl flag that lets the kernel forward. What's notable is where it came from: the same disposable test image that fed the entire lenny cluster investigation turned out to be a perfectly good starting point for something that has nothing to do with Kubernetes at all.