Files
tomswall/internal/shorewall/convert_test.go
T
unkinben 8d9a76c751 Add comprehensive nftables compiler with shorewall feature parity
Rewrites the compiler from ~440 to ~1700 lines covering all major shorewall
firewall features: loopback, conntrack fast-path, anti-spoof, DHCP, intra-zone,
blacklist/whitelist, conntrack notrack, tunnels (13 types), rules with sections,
DNAT/redirect, SNAT/masquerade, static NAT, policies with zone exclusions,
MSS clamping, rate limiting, connection limiting, negated addresses, ICMP type
matching, TCP RST reject, user/UID matching, mark match/set, NFQUEUE, NONAT,
and policy-level rate/conn limiting.

Adds full config types for all shorewall subsystems (mangle, accounting, maclist,
netmap, providers, tunnels, conntrack, blrules, proxyarp/ndp, routes, tc, secmarks),
shorewall migration tooling, expanded CLI commands, expression-level diff engine,
and 49 unit tests.
2026-07-01 23:56:44 +10:00

728 lines
16 KiB
Go

package shorewall
import (
"os"
"path/filepath"
"testing"
"git.unkin.net/unkin/tomswall/internal/config"
)
// writeFile is a helper to create a file in the temp dir.
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// minimalShorewallDir creates a temp directory with the minimum shorewall config
// files needed for Convert() to succeed.
func minimalShorewallDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", `
IP_FORWARDING=Yes
LOG_LEVEL=info
`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
loc ipv4
`)
writeFile(t, dir, "interfaces", `
net eth0 dhcp,tcpflags,routefilter,nosmurfs
loc eth1 tcpflags,nosmurfs
`)
writeFile(t, dir, "policy", `
loc net ACCEPT
net all DROP info
all all REJECT info
`)
// Create empty files for optional configs so ParseFile returns nil, nil
// (they would return nil,nil on os.IsNotExist anyway).
return dir
}
func TestConvert_MinimalConfig(t *testing.T) {
dir := minimalShorewallDir(t)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
// Check zones
if len(cfg.Zones) != 3 {
t.Fatalf("expected 3 zones, got %d", len(cfg.Zones))
}
fwZone, ok := cfg.Zones["fw"]
if !ok {
t.Fatal("expected fw zone")
}
if fwZone.Type != config.ZoneFirewall {
t.Errorf("fw zone type = %q, want %q", fwZone.Type, config.ZoneFirewall)
}
netZone, ok := cfg.Zones["net"]
if !ok {
t.Fatal("expected net zone")
}
if netZone.Type != config.ZoneIP {
t.Errorf("net zone type = %q, want %q", netZone.Type, config.ZoneIP)
}
locZone, ok := cfg.Zones["loc"]
if !ok {
t.Fatal("expected loc zone")
}
if locZone.Type != config.ZoneIP {
t.Errorf("loc zone type = %q, want %q", locZone.Type, config.ZoneIP)
}
}
func TestConvert_Interfaces(t *testing.T) {
dir := minimalShorewallDir(t)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if len(cfg.Interfaces) != 2 {
t.Fatalf("expected 2 interfaces, got %d", len(cfg.Interfaces))
}
// Check first interface (net eth0)
netIface := cfg.Interfaces[0]
if netIface.Zone != "net" {
t.Errorf("interface 0 zone = %q, want %q", netIface.Zone, "net")
}
if netIface.Interface != "eth0" {
t.Errorf("interface 0 name = %q, want %q", netIface.Interface, "eth0")
}
if !netIface.Options.DHCP {
t.Error("interface 0 should have DHCP enabled")
}
if netIface.Options.TCPFlags == nil || !*netIface.Options.TCPFlags {
t.Error("interface 0 should have tcpflags enabled")
}
if netIface.Options.RouteFilter == nil || *netIface.Options.RouteFilter != 1 {
t.Error("interface 0 should have routefilter=1")
}
if !netIface.Options.NoSmurfs {
t.Error("interface 0 should have nosmurfs enabled")
}
// Check second interface (loc eth1)
locIface := cfg.Interfaces[1]
if locIface.Zone != "loc" {
t.Errorf("interface 1 zone = %q, want %q", locIface.Zone, "loc")
}
if locIface.Interface != "eth1" {
t.Errorf("interface 1 name = %q, want %q", locIface.Interface, "eth1")
}
}
func TestConvert_Policy(t *testing.T) {
dir := minimalShorewallDir(t)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if len(cfg.Policy) != 3 {
t.Fatalf("expected 3 policies, got %d", len(cfg.Policy))
}
// loc -> net ACCEPT
p0 := cfg.Policy[0]
if p0.Source != "loc" || p0.Dest != "net" {
t.Errorf("policy 0: source=%q dest=%q, want loc/net", p0.Source, p0.Dest)
}
if p0.Action != config.PolicyAccept {
t.Errorf("policy 0 action = %q, want %q", p0.Action, config.PolicyAccept)
}
// net -> all DROP info
p1 := cfg.Policy[1]
if p1.Source != "net" || p1.Dest != "all" {
t.Errorf("policy 1: source=%q dest=%q, want net/all", p1.Source, p1.Dest)
}
if p1.Action != config.PolicyDrop {
t.Errorf("policy 1 action = %q, want %q", p1.Action, config.PolicyDrop)
}
if p1.Log != "info" {
t.Errorf("policy 1 log = %q, want %q", p1.Log, "info")
}
// all -> all REJECT info
p2 := cfg.Policy[2]
if p2.Action != config.PolicyReject {
t.Errorf("policy 2 action = %q, want %q", p2.Action, config.PolicyReject)
}
}
func TestConvert_Settings(t *testing.T) {
dir := minimalShorewallDir(t)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if cfg.Settings.TableName != "tomswall" {
t.Errorf("table name = %q, want %q", cfg.Settings.TableName, "tomswall")
}
if cfg.Settings.LogLevel != "info" {
t.Errorf("log level = %q, want %q", cfg.Settings.LogLevel, "info")
}
if !cfg.Settings.IPForwarding {
t.Error("IP forwarding should be enabled")
}
}
func TestConvert_ParamsSubstitution(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "params", `
NET_IF=eth0
LOC_IF=eth1
`)
writeFile(t, dir, "shorewall.conf", `
IP_FORWARDING=Yes
`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
loc ipv4
`)
writeFile(t, dir, "interfaces", `
net $NET_IF dhcp
loc $LOC_IF -
`)
writeFile(t, dir, "policy", `
loc net ACCEPT
all all DROP
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
// Verify params were stored
if cfg.Vars["NET_IF"] != "eth0" {
t.Errorf("Vars[NET_IF] = %q, want %q", cfg.Vars["NET_IF"], "eth0")
}
if cfg.Vars["LOC_IF"] != "eth1" {
t.Errorf("Vars[LOC_IF] = %q, want %q", cfg.Vars["LOC_IF"], "eth1")
}
// Verify substitution worked in interfaces
if len(cfg.Interfaces) != 2 {
t.Fatalf("expected 2 interfaces, got %d", len(cfg.Interfaces))
}
if cfg.Interfaces[0].Interface != "eth0" {
t.Errorf("interface 0 = %q, want %q (after param subst)", cfg.Interfaces[0].Interface, "eth0")
}
if cfg.Interfaces[1].Interface != "eth1" {
t.Errorf("interface 1 = %q, want %q (after param subst)", cfg.Interfaces[1].Interface, "eth1")
}
}
func TestConvert_ZonesWithParents(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
loc ipv4
dmz:net ipv4
`)
writeFile(t, dir, "interfaces", `
net eth0 -
loc eth1 -
`)
writeFile(t, dir, "policy", `
all all DROP
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
dmz, ok := cfg.Zones["dmz"]
if !ok {
t.Fatal("expected dmz zone")
}
if len(dmz.Parents) != 1 || dmz.Parents[0] != "net" {
t.Errorf("dmz parents = %v, want [net]", dmz.Parents)
}
}
func TestConvert_Rules(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
loc ipv4
`)
writeFile(t, dir, "interfaces", `
net eth0 -
loc eth1 -
`)
writeFile(t, dir, "policy", `
all all DROP
`)
writeFile(t, dir, "rules", `
SECTION NEW
ACCEPT net fw tcp 22
DROP net fw udp 53
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if len(cfg.Rules) != 2 {
t.Fatalf("expected 2 rules, got %d", len(cfg.Rules))
}
r0 := cfg.Rules[0]
if r0.Action != config.RuleAccept {
t.Errorf("rule 0 action = %q, want %q", r0.Action, config.RuleAccept)
}
if r0.Source != "net" {
t.Errorf("rule 0 source = %q, want %q", r0.Source, "net")
}
if r0.Dest != "fw" {
t.Errorf("rule 0 dest = %q, want %q", r0.Dest, "fw")
}
if r0.Proto != "tcp" {
t.Errorf("rule 0 proto = %q, want %q", r0.Proto, "tcp")
}
if len(r0.DPort) != 1 || r0.DPort[0] != "22" {
t.Errorf("rule 0 dport = %v, want [22]", r0.DPort)
}
if r0.Section != "new" {
t.Errorf("rule 0 section = %q, want %q", r0.Section, "new")
}
r1 := cfg.Rules[1]
if r1.Action != config.RuleDrop {
t.Errorf("rule 1 action = %q, want %q", r1.Action, config.RuleDrop)
}
}
func TestConvert_FWBuiltinVariable(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
`)
writeFile(t, dir, "interfaces", `
net eth0 -
`)
writeFile(t, dir, "policy", `
$FW all ACCEPT
net all DROP
all all REJECT
`)
writeFile(t, dir, "rules", `
SECTION NEW
ACCEPT net $FW tcp 22
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if cfg.Vars["FW"] != "fw" {
t.Errorf("Vars[FW] = %q, want %q", cfg.Vars["FW"], "fw")
}
if cfg.Policy[0].Source != "fw" {
t.Errorf("policy 0 source = %q, want %q (after $FW substitution)", cfg.Policy[0].Source, "fw")
}
if cfg.Rules[0].Dest != "fw" {
t.Errorf("rule 0 dest = %q, want %q (after $FW substitution)", cfg.Rules[0].Dest, "fw")
}
}
func TestConvert_ConntrackHelperChain(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
`)
writeFile(t, dir, "interfaces", `
net eth0 -
`)
writeFile(t, dir, "policy", `
all all DROP
`)
writeFile(t, dir, "conntrack", `
CT:helper:ftp:PO - - tcp 21
CT:helper:sip:P - - udp 5060
CT:helper:tftp:O - - udp 69
CT:helper:irc - - tcp 6667
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if len(cfg.Conntrack) != 4 {
t.Fatalf("expected 4 conntrack rules, got %d", len(cfg.Conntrack))
}
tests := []struct {
helper string
chain string
}{
{"ftp", "both"},
{"sip", "prerouting"},
{"tftp", "output"},
{"irc", ""},
}
for i, tt := range tests {
if cfg.Conntrack[i].Helper != tt.helper {
t.Errorf("conntrack[%d] helper = %q, want %q", i, cfg.Conntrack[i].Helper, tt.helper)
}
if string(cfg.Conntrack[i].Chain) != tt.chain {
t.Errorf("conntrack[%d] chain = %q, want %q", i, cfg.Conntrack[i].Chain, tt.chain)
}
}
}
func TestSplitHelperChain(t *testing.T) {
tests := []struct {
input string
wantName string
wantChain string
}{
{"ftp:PO", "ftp", "both"},
{"sip:P", "sip", "prerouting"},
{"tftp:O", "tftp", "output"},
{"irc", "irc", ""},
{"Q.931:PO", "Q.931", "both"},
{"netbios-ns:PO", "netbios-ns", "both"},
}
for _, tt := range tests {
name, chain := splitHelperChain(tt.input)
if name != tt.wantName {
t.Errorf("splitHelperChain(%q) name = %q, want %q", tt.input, name, tt.wantName)
}
if chain != tt.wantChain {
t.Errorf("splitHelperChain(%q) chain = %q, want %q", tt.input, chain, tt.wantChain)
}
}
}
func TestConvert_MultiZoneRules(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
loc ipv4
`)
writeFile(t, dir, "interfaces", `
net eth0 -
loc eth1 -
`)
writeFile(t, dir, "policy", `
all all DROP
`)
writeFile(t, dir, "rules", `
SECTION NEW
ACCEPT net,loc fw tcp 8080,8501
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if len(cfg.Rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(cfg.Rules))
}
if cfg.Rules[0].Source != "net,loc" {
t.Errorf("rule 0 source = %q, want %q", cfg.Rules[0].Source, "net,loc")
}
if err := cfg.Validate(); err != nil {
t.Errorf("multi-zone rule should validate: %v", err)
}
}
func TestConvert_ImplicitContinue(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", `
IP_FORWARDING=Yes
IMPLICIT_CONTINUE=Yes
`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
`)
writeFile(t, dir, "interfaces", `
net eth0 -
`)
writeFile(t, dir, "policy", `
all all DROP
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if !cfg.Settings.ImplicitContinue {
t.Error("ImplicitContinue should be true")
}
}
// --- shorewall6 tests ---
func TestConvert_IPv6Detection(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall6.conf", `
IP_FORWARDING=On
IMPLICIT_CONTINUE=Yes
`)
writeFile(t, dir, "zones", `
fw firewall
net ipv6
`)
writeFile(t, dir, "interfaces", `
net eth0 dhcp,accept_ra
`)
writeFile(t, dir, "policy", `
all all DROP
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if cfg.Settings.AddressFamily != config.FamilyIP6 {
t.Errorf("AddressFamily = %q, want %q", cfg.Settings.AddressFamily, config.FamilyIP6)
}
}
func TestConvert_IPv4Detection(t *testing.T) {
dir := minimalShorewallDir(t)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if cfg.Settings.AddressFamily != config.FamilyIP {
t.Errorf("AddressFamily = %q, want %q", cfg.Settings.AddressFamily, config.FamilyIP)
}
}
func TestConvert_IPv6ProxyNDP(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall6.conf", `IP_FORWARDING=On`)
writeFile(t, dir, "zones", `
fw firewall
net ipv6
`)
writeFile(t, dir, "interfaces", `
net eth0 -
`)
writeFile(t, dir, "policy", `
all all DROP
`)
writeFile(t, dir, "proxyndp", `
fd10::100 eth0 eth1 No Yes
2001:db8::1 eth2 eth3 Yes No
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if len(cfg.ProxyNDP) != 2 {
t.Fatalf("expected 2 proxyndp entries, got %d", len(cfg.ProxyNDP))
}
p0 := cfg.ProxyNDP[0]
if p0.Address != "fd10::100" {
t.Errorf("proxyndp[0] address = %q, want %q", p0.Address, "fd10::100")
}
if p0.Interface != "eth0" {
t.Errorf("proxyndp[0] interface = %q, want %q", p0.Interface, "eth0")
}
if p0.External != "eth1" {
t.Errorf("proxyndp[0] external = %q, want %q", p0.External, "eth1")
}
if p0.HaveRoute {
t.Error("proxyndp[0] haveroute should be false")
}
if !p0.Persistent {
t.Error("proxyndp[0] persistent should be true")
}
p1 := cfg.ProxyNDP[1]
if !p1.HaveRoute {
t.Error("proxyndp[1] haveroute should be true")
}
if p1.Persistent {
t.Error("proxyndp[1] persistent should be false")
}
// IPv6 config should NOT have proxyarp entries
if len(cfg.ProxyARP) != 0 {
t.Errorf("IPv6 config should have 0 proxyarp entries, got %d", len(cfg.ProxyARP))
}
}
func TestConvert_IPv6AcceptRA(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall6.conf", `IP_FORWARDING=On`)
writeFile(t, dir, "zones", `
fw firewall
net ipv6
`)
writeFile(t, dir, "interfaces", `
net eth0 dhcp,accept_ra=2
`)
writeFile(t, dir, "policy", `
all all DROP
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if len(cfg.Interfaces) != 1 {
t.Fatalf("expected 1 interface, got %d", len(cfg.Interfaces))
}
opts := cfg.Interfaces[0].Options
if opts.AcceptRA == nil {
t.Fatal("accept_ra should be set")
}
if *opts.AcceptRA != 2 {
t.Errorf("accept_ra = %d, want 2", *opts.AcceptRA)
}
}
func TestConvert_IPv6ZoneTypes(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall6.conf", `IP_FORWARDING=On`)
writeFile(t, dir, "zones", `
fw firewall
net ipv6
vpn ipsec6
dmz bport6
`)
writeFile(t, dir, "interfaces", `
net eth0 -
vpn ipsec0 -
dmz br0 -
`)
writeFile(t, dir, "policy", `
all all DROP
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if cfg.Zones["net"].Type != config.ZoneIP {
t.Errorf("net zone type = %q, want %q", cfg.Zones["net"].Type, config.ZoneIP)
}
if cfg.Zones["vpn"].Type != config.ZoneIPSec {
t.Errorf("vpn zone type = %q, want %q", cfg.Zones["vpn"].Type, config.ZoneIPSec)
}
if cfg.Zones["dmz"].Type != config.ZoneBPort {
t.Errorf("dmz zone type = %q, want %q", cfg.Zones["dmz"].Type, config.ZoneBPort)
}
}
func TestConvert_SecmarksConverter(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`)
writeFile(t, dir, "zones", `
fw firewall
net ipv4
`)
writeFile(t, dir, "interfaces", `
net eth0 -
`)
writeFile(t, dir, "policy", `
all all DROP
`)
writeFile(t, dir, "secmarks", `
system_u:object_r:http_t:s0 P net fw tcp 80
`)
cfg, err := Convert(dir)
if err != nil {
t.Fatalf("Convert: %v", err)
}
if len(cfg.Secmarks) != 1 {
t.Fatalf("expected 1 secmark, got %d", len(cfg.Secmarks))
}
if cfg.Secmarks[0].Secmark != "system_u:object_r:http_t:s0" {
t.Errorf("secmark = %q", cfg.Secmarks[0].Secmark)
}
if cfg.Secmarks[0].Proto != "tcp" {
t.Errorf("proto = %q, want tcp", cfg.Secmarks[0].Proto)
}
}
func TestIsIPv6Dir(t *testing.T) {
t.Run("shorewall6 dir", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall6.conf", "IP_FORWARDING=On\n")
if !IsIPv6Dir(dir) {
t.Error("should detect IPv6 dir")
}
})
t.Run("shorewall dir", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "shorewall.conf", "IP_FORWARDING=Yes\n")
if IsIPv6Dir(dir) {
t.Error("should not detect IPv6 for shorewall dir")
}
})
}