eib-fw 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
01-fix-growfs.sh. 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 a case of leaving RKE2 installed but empty — the kubernetes:
section is gone from edge-cluster.yaml entirely, along with everything that only
existed to support it:
kubernetes/config/server.yamlandagent.yaml— removed, no RKE2 role to configurecustom/scripts/02-tmpfs-cni.sh— removed, no CNI to give a clean/etc/cnitorpms/gpg-keys/rancher-public.key— removed, unused once Longhorn/Rancher were never in the picture hereos-files/etc/profile.local— rewritten;kubectlaliases replaced withnft/conntrackones
custom/scripts/01-fix-growfs.sh is the one script that survived untouched —
the root-filesystem grow-on-first-boot fix applies regardless of what's actually running on top
of it.
One NIC Becomes Two
The single static-IP interface from the test image became a proper WAN/LAN split: DHCP on the upstream side, the appliance itself as the gateway on the protected side.
interfaces:
- name: enp1s0 # WAN — upstream/ISP-facing
type: ethernet
state: up
mac-address: 52:54:00:20:79:d2
ipv4:
enabled: true
dhcp: true
ipv6:
enabled: false
- name: enp2s0 # LAN — protected network, appliance is the gateway
type: ethernet
state: up
mac-address: 52:54:00:20:79:d3
ipv4:
enabled: true
dhcp: false
address:
- ip: 192.168.100.1
prefix-length: 24
ipv6:
enabled: false
The old single default-route/static-IP config disappeared along with it — routing and DNS now arrive automatically via DHCP on the WAN side, the way an actual ISP-facing box would get them.
The Ruleset
os-files/etc/nftables.conf is a deliberately small, readable base: drop by default
on every chain that matters, accept established/related connections statefully, and only allow
what's explicitly intended.
define WAN_IF = enp1s0
define LAN_IF = enp2s0
define LAN_NET = 192.168.100.0/24
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif lo accept
ct state invalid drop
ct state established,related accept
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# management access from LAN only
iifname $LAN_IF tcp dport 22 accept
}
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
}
chain output {
type filter hook output priority 0; policy 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 on input and
forward means nothing gets through by accident; ct state
established,related accept means return traffic for connections LAN clients actually
opened just works without needing its own explicit rule; and the single iifname $LAN_IF
oifname $WAN_IF accept line in forward is the entire "LAN can reach the
internet, nothing from the internet can reach LAN" policy in one line — there's no
matching WAN→LAN rule, so the chain's drop default handles that direction with
nothing extra needed.
iifname $LAN_IF tcp dport 22 accept in
input is the only way in for management — there's no equivalent rule for the
WAN interface, so the appliance is unreachable for SSH from the internet side by construction,
not by a separate rule that has to remember to exclude it.
nftables was added to systemd.enable in edge-cluster.yaml so this ruleset actually loads on boot rather than sitting there unused.
Making It Actually Route
nftables handles the packet filtering, but the kernel still needs telling it's allowed to forward packets between interfaces at all — that's off by default on a general-purpose Linux install:
# Required for the appliance to route between WAN and LAN
net.ipv4.ip_forward = 1
# Hardening
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 underneath earn their place on a box that's specifically sitting at a network boundary: no accepting ICMP redirects that could silently repoint routes, no responding to source-routed packets, strict reverse-path filtering so packets claiming to be from LAN addresses can't sneak in the WAN interface, and martian packets get logged instead of silently dropped.
Testing It Needed Two Networks, Not One
test-vm.sh went from one libvirt NAT network to two: eib-fw-wan
(NAT'd to the host, standing in for the upstream ISP link, complete with its own DHCP range) and
eib-fw-lan — an isolated bridge with no libvirt-side IP or DHCP of its own, so
the appliance's static 192.168.100.1 is the only address on that segment, exactly
like it would be on real hardware.
eib-fw-lan and
give it a static address in 192.168.100.0/24 with 192.168.100.1 as its
gateway — there's no DHCP server on that segment to do it automatically, on purpose.
| Package | Change | Reason |
|---|---|---|
nftables | added | the firewall/NAT engine itself |
tcpdump | added | traffic troubleshooting |
conntrack-tools | added | connection-tracking visibility (conntrack -L) |
jq, openssh-server-config-rootlogin | kept | scripting, remote management |
Nothing here is exotic — it's a small, readable nftables ruleset plus the sysctl flag that lets the kernel actually forward. What's notable is where it came from: the same disposable, stripped-down 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.
Blog