e0f54ef320
Add `tomswall agent`: it pulls this device's compiled config from tomswallapi, differentially applies it, and reports the applied generation. It caches the last known-good config and, when the control plane is unreachable, keeps applying that cache — it never fails closed. - internal/agent: rendered-config types, HTTP client (fetch + status report), on-disk cache, on-device DNS resolver for dns sets (honors the device's configured resolver, fail-safe on lookup failure), and the pull-apply-report loop behind a mockable Applier. - Translate the interface-agnostic, address-matched rendered model into native tomswall config using the "all:<cidr>" any-interface source/dest form, reusing the existing differential engine. Named-set members are inlined as concrete addresses (native nft set references are a tracked follow-up). - cmd/tomswall: wire the `agent` subcommand (flags + TOMSWALL_* env, --once). - Unit tests: translation, cache, and the don't-fail-closed fallback loop. - Add DESIGN.md documenting the control-plane architecture.
1009 lines
23 KiB
Go
1009 lines
23 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|