Blog

From RKE2 Test Image to nftables Firewall Appliance

SUSE Edge Image Builder (EIB) Networking & Security
2026-07-17 8 min read

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:

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.

network/node1.shed.local.yaml
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.

os-files/etc/nftables.conf
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.

SSH is LAN-only: 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:

os-files/etc/sysctl.d/95-firewall.conf
# 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.

Testing the LAN side: attach a second VM's NIC to 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.
PackageChangeReason
nftablesaddedthe firewall/NAT engine itself
tcpdumpaddedtraffic troubleshooting
conntrack-toolsaddedconnection-tracking visibility (conntrack -L)
jq, openssh-server-config-rootloginkeptscripting, 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.

nftables Firewall Edge Image Builder SUSE Networking
Blog