package config import ( "os" "path/filepath" "strings" "testing" ) // baseConfig returns a minimal valid Config with a firewall zone and policy. // Tests append their own fields to this base. func baseConfig() Config { return Config{ Settings: Settings{AddressFamily: FamilyINET}, Zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "loc": {Type: ZoneIP}, }, Interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, {Zone: "loc", Interface: "eth1"}, }, Policy: []Policy{ {Source: "all", Dest: "all", Action: PolicyDrop}, }, } } // checkErr is a test helper that verifies error expectations. func checkErr(t *testing.T, err error, wantErr string) { t.Helper() if wantErr == "" { if err != nil { t.Fatalf("unexpected error: %v", err) } return } if err == nil { t.Fatalf("expected error containing %q, got nil", wantErr) } if !strings.Contains(err.Error(), wantErr) { t.Fatalf("expected error containing %q, got: %v", wantErr, err) } } // --------------------------------------------------------------------------- // Load // --------------------------------------------------------------------------- func TestLoad_YAML(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") data := []byte(` zones: fw: type: firewall net: type: ip policy: - source: net dest: fw action: drop settings: ip_forwarding: true `) if err := os.WriteFile(path, data, 0644); err != nil { t.Fatal(err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load YAML: %v", err) } if cfg.Zones["fw"].Type != ZoneFirewall { t.Errorf("expected firewall zone type, got %q", cfg.Zones["fw"].Type) } if !cfg.Settings.IPForwarding { t.Error("expected ip_forwarding to be true") } // applyDefaults should fill in missing fields. if cfg.Settings.TableName != "tomswall" { t.Errorf("expected default table name %q, got %q", "tomswall", cfg.Settings.TableName) } if cfg.Settings.LogLevel != "info" { t.Errorf("expected default log level %q, got %q", "info", cfg.Settings.LogLevel) } } func TestLoad_JSON(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") data := []byte(`{ "zones": { "fw": {"type": "firewall"}, "net": {"type": "ip"} }, "policy": [ {"source": "net", "dest": "fw", "action": "drop"} ] }`) if err := os.WriteFile(path, data, 0644); err != nil { t.Fatal(err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load JSON: %v", err) } if cfg.Zones["fw"].Type != ZoneFirewall { t.Errorf("expected firewall zone type, got %q", cfg.Zones["fw"].Type) } if cfg.Settings.TableName != "tomswall" { t.Errorf("expected default table name, got %q", cfg.Settings.TableName) } } func TestLoad_FileNotFound(t *testing.T) { _, err := Load("/nonexistent/path/config.yaml") if err == nil { t.Fatal("expected error for missing file") } } func TestLoad_InvalidYAML(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "bad.yaml") if err := os.WriteFile(path, []byte("{{bad yaml"), 0644); err != nil { t.Fatal(err) } _, err := Load(path) if err == nil { t.Fatal("expected error for invalid YAML") } } func TestLoad_InvalidJSON(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "bad.json") if err := os.WriteFile(path, []byte("{bad json}"), 0644); err != nil { t.Fatal(err) } _, err := Load(path) if err == nil { t.Fatal("expected error for invalid JSON") } } // --------------------------------------------------------------------------- // Zones // --------------------------------------------------------------------------- func TestValidateZones(t *testing.T) { tests := []struct { name string zones map[string]Zone wantErr string }{ { name: "valid minimal", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, }, { name: "all valid zone types", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "vpn": {Type: ZoneIPSec}, "br": {Type: ZoneBPort}, "lo": {Type: ZoneLoopback}, "loc": {Type: ZoneLocal}, }, }, { name: "valid parent zone", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "sub": {Type: ZoneIP, Parents: []string{"net"}}, }, }, { name: "no zones defined", zones: map[string]Zone{}, wantErr: "no zones defined", }, { name: "missing firewall zone", zones: map[string]Zone{ "net": {Type: ZoneIP}, "dmz": {Type: ZoneIP}, }, wantErr: "exactly one firewall zone required, found 0", }, { name: "two firewall zones", zones: map[string]Zone{ "fw1": {Type: ZoneFirewall}, "fw2": {Type: ZoneFirewall}, }, wantErr: "exactly one firewall zone required, found 2", }, { name: "invalid zone name starts with digit", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "1bad": {Type: ZoneIP}, }, wantErr: "must start with a letter", }, { name: "invalid character in zone name", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net-1": {Type: ZoneIP}, }, wantErr: "invalid character", }, { name: "reserved zone name all", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "all": {Type: ZoneIP}, }, wantErr: "reserved name", }, { name: "reserved zone name none", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "none": {Type: ZoneIP}, }, wantErr: "reserved name", }, { name: "reserved zone name any", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "any": {Type: ZoneIP}, }, wantErr: "reserved name", }, { name: "reserved zone name SOURCE", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "SOURCE": {Type: ZoneIP}, }, wantErr: "reserved name", }, { name: "reserved zone name DEST", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "DEST": {Type: ZoneIP}, }, wantErr: "reserved name", }, { name: "unknown zone type", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: "bogus"}, }, wantErr: "unknown type", }, { name: "empty zone type", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ""}, }, wantErr: "type required", }, { name: "parent zone not defined", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP, Parents: []string{"missing"}}, }, wantErr: "parent zone \"missing\" not defined", }, { name: "firewall zone with options", zones: map[string]Zone{ "fw": {Type: ZoneFirewall, Options: []string{"notrack"}}, }, wantErr: "firewall zone does not accept options", }, { name: "firewall zone with in_options", zones: map[string]Zone{ "fw": {Type: ZoneFirewall, InOptions: []string{"notrack"}}, }, wantErr: "firewall zone does not accept options", }, { name: "firewall zone with out_options", zones: map[string]Zone{ "fw": {Type: ZoneFirewall, OutOptions: []string{"notrack"}}, }, wantErr: "firewall zone does not accept options", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{Zones: tt.zones} err := cfg.validateZones() checkErr(t, err, tt.wantErr) }) } } // --------------------------------------------------------------------------- // Interfaces // --------------------------------------------------------------------------- func TestValidateInterfaces(t *testing.T) { tests := []struct { name string interfaces []Interface wantErr string }{ { name: "valid single interface", interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, }, { name: "valid empty list", interfaces: nil, }, { name: "firewall zone forbidden", interfaces: []Interface{ {Zone: "fw", Interface: "eth0"}, }, wantErr: "firewall zone must not be listed in interfaces", }, { name: "virtual interface rejected", interfaces: []Interface{ {Zone: "net", Interface: "eth0:0"}, }, wantErr: "virtual interfaces", }, { name: "optional and required mutually exclusive", interfaces: []Interface{ {Zone: "net", Interface: "eth0", Options: InterfaceOptions{Optional: true, Required: true}}, }, wantErr: "optional and required are mutually exclusive", }, { name: "zone not defined", interfaces: []Interface{ {Zone: "missing", Interface: "eth0"}, }, wantErr: "zone \"missing\" not defined", }, { name: "duplicate interface", interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, {Zone: "net", Interface: "eth0"}, }, wantErr: "duplicate interface", }, { name: "empty interface name", interfaces: []Interface{ {Zone: "net", Interface: ""}, }, wantErr: "interface name required", }, { name: "unmanaged with zone set", interfaces: []Interface{ {Zone: "net", Interface: "eth0", Options: InterfaceOptions{Unmanaged: true}}, }, wantErr: "unmanaged interfaces must have an empty zone", }, { name: "unmanaged without zone is valid", interfaces: []Interface{ {Zone: "", Interface: "eth0", Options: InterfaceOptions{Unmanaged: true}}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := baseConfig() cfg.Interfaces = tt.interfaces err := cfg.validateInterfaces() checkErr(t, err, tt.wantErr) }) } } // --------------------------------------------------------------------------- // Hosts // --------------------------------------------------------------------------- func TestValidateHosts(t *testing.T) { tests := []struct { name string zones map[string]Zone interfaces []Interface hosts []Host wantErr string }{ { name: "valid host", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "loc": {Type: ZoneIP}, }, interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, hosts: []Host{ {Zone: "loc", Interface: "eth0", Addresses: []string{"192.168.1.0/24"}}, }, }, { name: "valid dynamic host", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "loc": {Type: ZoneIP}, }, interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, hosts: []Host{ {Zone: "loc", Interface: "eth0", Dynamic: true}, }, }, { name: "firewall zone forbidden", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, hosts: []Host{ {Zone: "fw", Interface: "eth0", Addresses: []string{"10.0.0.1"}}, }, wantErr: "firewall zone must not be listed in hosts", }, { name: "missing interface", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "loc": {Type: ZoneIP}, }, interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, hosts: []Host{ {Zone: "loc", Interface: "eth99", Addresses: []string{"10.0.0.1"}}, }, wantErr: "interface \"eth99\" not defined in interfaces", }, { name: "zone not defined", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, hosts: []Host{ {Zone: "missing", Interface: "eth0", Addresses: []string{"10.0.0.1"}}, }, wantErr: "zone \"missing\" not defined", }, { name: "no address and not dynamic", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "loc": {Type: ZoneIP}, }, interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, hosts: []Host{ {Zone: "loc", Interface: "eth0"}, }, wantErr: "at least one address required", }, { name: "empty zone", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, hosts: []Host{ {Zone: "", Interface: "eth0", Addresses: []string{"10.0.0.1"}}, }, wantErr: "zone required", }, { name: "empty interface", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, "loc": {Type: ZoneIP}, }, interfaces: []Interface{ {Zone: "net", Interface: "eth0"}, }, hosts: []Host{ {Zone: "loc", Interface: "", Addresses: []string{"10.0.0.1"}}, }, wantErr: "interface required", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{ Zones: tt.zones, Interfaces: tt.interfaces, Hosts: tt.hosts, Policy: []Policy{{Source: "net", Dest: "fw", Action: PolicyDrop}}, } err := cfg.validateHosts() checkErr(t, err, tt.wantErr) }) } } // --------------------------------------------------------------------------- // Port groups // --------------------------------------------------------------------------- func TestValidatePortGroups(t *testing.T) { tests := []struct { name string portgroups map[string]PortGroup wantErr string }{ { name: "valid tcp portgroup", portgroups: map[string]PortGroup{ "web": {Proto: "tcp", Ports: PortSpec{"80", "443"}}, }, }, { name: "valid udp portgroup", portgroups: map[string]PortGroup{ "dns": {Proto: "udp", Ports: PortSpec{"53"}}, }, }, { name: "valid port range", portgroups: map[string]PortGroup{ "high": {Proto: "tcp", Ports: PortSpec{"1024-65535"}}, }, }, { name: "missing proto", portgroups: map[string]PortGroup{ "web": {Proto: "", Ports: PortSpec{"80"}}, }, wantErr: "proto required", }, { name: "invalid proto", portgroups: map[string]PortGroup{ "web": {Proto: "icmp", Ports: PortSpec{"80"}}, }, wantErr: "proto must be tcp or udp", }, { name: "empty ports", portgroups: map[string]PortGroup{ "web": {Proto: "tcp", Ports: PortSpec{}}, }, wantErr: "at least one port required", }, { name: "no portgroups is valid", portgroups: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := baseConfig() cfg.PortGroups = tt.portgroups err := cfg.validatePortGroups() checkErr(t, err, tt.wantErr) }) } } // --------------------------------------------------------------------------- // Policy // --------------------------------------------------------------------------- func TestValidatePolicy(t *testing.T) { tests := []struct { name string zones map[string]Zone policy []Policy wantErr string }{ { name: "valid policy", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: []Policy{ {Source: "net", Dest: "fw", Action: PolicyDrop}, {Source: "fw", Dest: "net", Action: PolicyAccept}, }, }, { name: "valid with all keyword", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: []Policy{ {Source: "all", Dest: "all", Action: PolicyReject}, }, }, { name: "no policies", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: nil, wantErr: "no policies defined", }, { name: "unknown action", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: []Policy{ {Source: "net", Dest: "fw", Action: "explode"}, }, wantErr: "unknown action", }, { name: "source zone not defined", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: []Policy{ {Source: "missing", Dest: "fw", Action: PolicyDrop}, }, wantErr: "source zone \"missing\" not defined", }, { name: "dest zone not defined", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: []Policy{ {Source: "net", Dest: "missing", Action: PolicyDrop}, }, wantErr: "dest zone \"missing\" not defined", }, { name: "source required", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: []Policy{ {Source: "", Dest: "fw", Action: PolicyDrop}, }, wantErr: "source required", }, { name: "dest required", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: []Policy{ {Source: "net", Dest: "", Action: PolicyDrop}, }, wantErr: "dest required", }, { name: "valid all actions", zones: map[string]Zone{ "fw": {Type: ZoneFirewall}, "net": {Type: ZoneIP}, }, policy: []Policy{ {Source: "net", Dest: "fw", Action: PolicyAccept}, {Source: "fw", Dest: "net", Action: PolicyDrop}, {Source: "all", Dest: "all", Action: PolicyReject}, {Source: "net", Dest: "net", Action: PolicyContinue}, {Source: "net", Dest: "net", Action: PolicyNone}, {Source: "all", Dest: "all", Action: PolicyQueue}, {Source: "all", Dest: "all", Action: PolicyNFQueue}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{Zones: tt.zones, Policy: tt.policy} err := cfg.validatePolicy() checkErr(t, err, tt.wantErr) }) } } // --------------------------------------------------------------------------- // Rules // --------------------------------------------------------------------------- func TestValidateRules(t *testing.T) { tests := []struct { name string portgroups map[string]PortGroup rules []Rule wantErr string }{ { name: "valid accept rule", rules: []Rule{ {Action: RuleAccept, Source: "net", Dest: "fw", Proto: "tcp", DPort: PortSpec{"22"}}, }, }, { name: "valid rule with portgroup", portgroups: map[string]PortGroup{ "web": {Proto: "tcp", Ports: PortSpec{"80", "443"}}, }, rules: []Rule{ {Action: RuleAccept, Source: "net", Dest: "fw", PortGroup: "web"}, }, }, { name: "unknown action", rules: []Rule{ {Action: "explode", Source: "net", Dest: "fw"}, }, wantErr: "unknown action", }, { name: "portgroup mutually exclusive with proto", portgroups: map[string]PortGroup{ "web": {Proto: "tcp", Ports: PortSpec{"80"}}, }, rules: []Rule{ {Action: RuleAccept, Source: "net", Dest: "fw", PortGroup: "web", Proto: "tcp"}, }, wantErr: "portgroup is mutually exclusive with proto/dport", }, { name: "portgroup mutually exclusive with dport", portgroups: map[string]PortGroup{ "web": {Proto: "tcp", Ports: PortSpec{"80"}}, }, rules: []Rule{ {Action: RuleAccept, Source: "net", Dest: "fw", PortGroup: "web", DPort: PortSpec{"443"}}, }, wantErr: "portgroup is mutually exclusive with proto/dport", }, { name: "portgroup not defined", rules: []Rule{ {Action: RuleAccept, Source: "net", Dest: "fw", PortGroup: "missing"}, }, wantErr: "portgroup \"missing\" not defined", }, { name: "user requires firewall source", rules: []Rule{ {Action: RuleAccept, Source: "net", Dest: "fw", User: "nobody"}, }, wantErr: "user match only valid when source is the firewall zone", }, { name: "user with firewall source is valid", rules: []Rule{ {Action: RuleAccept, Source: "fw", Dest: "net", User: "nobody", Proto: "tcp", DPort: PortSpec{"80"}}, }, }, { name: "mark action requires set_mark", rules: []Rule{ {Action: RuleMark, Source: "net", Dest: "fw"}, }, wantErr: "set_mark required for mark action", }, { name: "connmark action requires set_mark", rules: []Rule{ {Action: RuleConnMark, Source: "net", Dest: "fw"}, }, wantErr: "set_mark required for connmark action", }, { name: "mark with set_mark is valid", rules: []Rule{ {Action: RuleMark, Source: "net", Dest: "fw", SetMark: "0x1"}, }, }, { name: "connmark with set_mark is valid", rules: []Rule{ {Action: RuleConnMark, Source: "net", Dest: "fw", SetMark: "0x2/0xff"}, }, }, { name: "source required", rules: []Rule{ {Action: RuleAccept, Source: "", Dest: "fw"}, }, wantErr: "source required", }, { name: "dest required", rules: []Rule{ {Action: RuleAccept, Source: "net", Dest: ""}, }, wantErr: "dest required", }, { name: "source zone not defined", rules: []Rule{ {Action: RuleAccept, Source: "missing", Dest: "fw"}, }, wantErr: "source zone \"missing\" not defined", }, { name: "all keyword is valid source", rules: []Rule{ {Action: RuleDrop, Source: "all", Dest: "all"}, }, }, { name: "tarpit with non-tcp proto", rules: []Rule{ {Action: RuleTarpit, Source: "net", Dest: "fw", Proto: "udp"}, }, wantErr: "tarpit only works with proto tcp", }, { name: "tarpit with tcp is valid", rules: []Rule{ {Action: RuleTarpit, Source: "net", Dest: "fw", Proto: "tcp"}, }, }, { name: "empty rules list is valid", rules: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := baseConfig() cfg.PortGroups = tt.portgroups cfg.Rules = tt.rules err := cfg.validateRules() checkErr(t, err, tt.wantErr) }) } } // --------------------------------------------------------------------------- // SNAT // --------------------------------------------------------------------------- func TestValidateSNAT(t *testing.T) { tests := []struct { name string snat []SNATRule wantErr string }{ { name: "valid masquerade", snat: []SNATRule{ {Action: SNATMasquerade, Dest: "eth0"}, }, }, { name: "valid snat with address", snat: []SNATRule{ {Action: SNATAddress, Address: "1.2.3.4", Dest: "eth0"}, }, }, { name: "valid continue", snat: []SNATRule{ {Action: SNATContinue, Dest: "eth0"}, }, }, { name: "valid log", snat: []SNATRule{ {Action: SNATLog, Dest: "eth0"}, }, }, { name: "unknown action", snat: []SNATRule{ {Action: "bogus", Dest: "eth0"}, }, wantErr: "unknown action", }, { name: "address required for snat action", snat: []SNATRule{ {Action: SNATAddress, Address: "", Dest: "eth0"}, }, wantErr: "address required for snat action", }, { name: "dest required", snat: []SNATRule{ {Action: SNATMasquerade, Dest: ""}, }, wantErr: "dest required", }, { name: "probability too high", snat: []SNATRule{ {Action: SNATMasquerade, Dest: "eth0", Probability: 1.5}, }, wantErr: "probability must be between 0", }, { name: "probability negative", snat: []SNATRule{ {Action: SNATMasquerade, Dest: "eth0", Probability: -0.5}, }, wantErr: "probability must be between 0", }, { name: "probability exactly 1 is valid", snat: []SNATRule{ {Action: SNATMasquerade, Dest: "eth0", Probability: 1.0}, }, }, { name: "probability 0.5 is valid", snat: []SNATRule{ {Action: SNATMasquerade, Dest: "eth0", Probability: 0.5}, }, }, { name: "probability 0 is valid (skipped)", snat: []SNATRule{ {Action: SNATMasquerade, Dest: "eth0", Probability: 0}, }, }, { name: "port_range requires proto", snat: []SNATRule{ {Action: SNATMasquerade, Dest: "eth0", PortRange: "1024-65535"}, }, wantErr: "port_range requires proto", }, { name: "persistent requires address", snat: []SNATRule{ {Action: SNATMasquerade, Dest: "eth0", Persistent: true}, }, wantErr: "persistent requires an address", }, { name: "empty snat list is valid", snat: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := baseConfig() cfg.SNAT = tt.snat err := cfg.validateSNAT() checkErr(t, err, tt.wantErr) }) } }