diff --git a/.gitignore b/.gitignore index 20a75fe..c140932 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -tomswall +/tomswall *.test diff --git a/cmd/tomswall/main.go b/cmd/tomswall/main.go index 209397d..2cd6ca5 100644 --- a/cmd/tomswall/main.go +++ b/cmd/tomswall/main.go @@ -1,13 +1,17 @@ package main import ( + "encoding/json" "fmt" "os" + "path/filepath" + "strings" "github.com/spf13/cobra" "git.unkin.net/unkin/tomswall/internal/config" "git.unkin.net/unkin/tomswall/internal/nftables" + "git.unkin.net/unkin/tomswall/internal/shorewall" ) var configPath string @@ -16,11 +20,27 @@ func main() { root := &cobra.Command{ Use: "tomswall", Short: "nftables firewall manager — spiritual successor to shorewall", + Long: `tomswall is a firewall manager that interacts directly with the kernel's +nftables subsystem via the google/nftables library. It supports differential +rule application — no firewall teardown/rebuild needed. + +Configuration can be provided in YAML, JSON, or legacy shorewall format. +Use 'tomswall migrate' to convert a shorewall config to YAML.`, + SilenceUsage: true, } - root.PersistentFlags().StringVarP(&configPath, "config", "c", "/etc/tomswall/tomswall.yaml", "path to configuration file") + root.PersistentFlags().StringVarP(&configPath, "config", "c", "/etc/tomswall/tomswall.yaml", "path to configuration file or shorewall directory") - root.AddCommand(applyCmd(), checkCmd(), statusCmd(), purgeCmd(), flushCmd()) + root.AddCommand( + applyCmd(), + planCmd(), + validateCmd(), + statusCmd(), + purgeCmd(), + flushCmd(), + migrateCmd(), + completionCmd(), + ) if err := root.Execute(); err != nil { os.Exit(1) @@ -28,10 +48,24 @@ func main() { } func loadConfig() (*config.Config, error) { - cfg, err := config.Load(configPath) + info, err := os.Stat(configPath) if err != nil { - return nil, err + return nil, fmt.Errorf("config path %s: %w", configPath, err) } + + var cfg *config.Config + if info.IsDir() { + cfg, err = shorewall.Convert(configPath) + if err != nil { + return nil, fmt.Errorf("converting shorewall config: %w", err) + } + } else { + cfg, err = config.Load(configPath) + if err != nil { + return nil, err + } + } + if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("validation: %w", err) } @@ -39,10 +73,12 @@ func loadConfig() (*config.Config, error) { } func applyCmd() *cobra.Command { - var dryRun bool cmd := &cobra.Command{ Use: "apply", Short: "Apply configuration to nftables (differential)", + Long: `Apply computes the difference between the desired configuration and the +current nftables state, then applies only the necessary changes atomically. +The firewall is never torn down — existing connections are preserved.`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { @@ -66,10 +102,6 @@ func applyCmd() *cobra.Command { fmt.Println(changes.Summary()) - if dryRun { - return nil - } - if err := engine.Apply(changes); err != nil { return fmt.Errorf("applying changes: %w", err) } @@ -77,14 +109,53 @@ func applyCmd() *cobra.Command { return nil }, } - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned changes without applying") return cmd } -func checkCmd() *cobra.Command { +func planCmd() *cobra.Command { return &cobra.Command{ - Use: "check", - Short: "Validate configuration without applying", + Use: "plan", + Short: "Show planned changes without applying (dry-run)", + Long: `Plan computes the difference between the desired configuration and the +current nftables state and displays what would change, without modifying +the firewall.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + engine, err := nftables.NewEngine(cfg) + if err != nil { + return fmt.Errorf("initializing nftables: %w", err) + } + + changes, err := engine.Plan() + if err != nil { + return fmt.Errorf("computing changes: %w", err) + } + + if changes.Empty() { + fmt.Println("No changes needed — firewall is up to date.") + return nil + } + + fmt.Println("Planned changes:") + fmt.Println(changes.Summary()) + return nil + }, + } +} + +func validateCmd() *cobra.Command { + return &cobra.Command{ + Use: "validate", + Short: "Validate configuration files", + Long: `Validate loads and validates the configuration without connecting to +nftables. Checks all config sections for correctness: zones, interfaces, +hosts, policy, rules, SNAT, NAT, netmap, providers, and all other sections. + +Accepts YAML, JSON, or a shorewall config directory.`, RunE: func(cmd *cobra.Command, args []string) error { _, err := loadConfig() if err != nil { @@ -213,3 +284,146 @@ func flushCmd() *cobra.Command { }, } } + +func migrateCmd() *cobra.Command { + var outputFormat string + var outputPath string + + cmd := &cobra.Command{ + Use: "migrate [shorewall-dir]", + Short: "Convert a shorewall/shorewall6 config directory to tomswall format", + Long: `Migrate reads a shorewall or shorewall6 configuration directory and converts +it to tomswall YAML or JSON format. The original config is never modified. +Auto-detects IPv6 mode when shorewall6.conf is present. + +Examples: + tomswall migrate # /etc/shorewall -> stdout + tomswall migrate /etc/shorewall -o config.yaml + tomswall migrate /etc/shorewall6 -o config6.yaml + tomswall migrate /etc/shorewall -f json -o config.json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir := "/etc/shorewall" + if len(args) > 0 { + dir = args[0] + } + + if !shorewall.DirExists(dir) { + return fmt.Errorf("not a valid shorewall config directory: %s", dir) + } + + cfg, err := shorewall.Convert(dir) + if err != nil { + return fmt.Errorf("converting: %w", err) + } + + if err := cfg.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: converted config has validation issues: %v\n", err) + } + + var data []byte + switch strings.ToLower(outputFormat) { + case "json": + data, err = json.MarshalIndent(cfg, "", " ") + default: + data, err = cfg.ToYAML() + } + if err != nil { + return fmt.Errorf("serializing: %w", err) + } + + if outputPath == "" || outputPath == "-" { + fmt.Print(string(data)) + } else { + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + if err := os.WriteFile(outputPath, data, 0644); err != nil { + return fmt.Errorf("writing output: %w", err) + } + fmt.Fprintf(os.Stderr, "Written to %s\n", outputPath) + } + + return nil + }, + } + cmd.Flags().StringVarP(&outputFormat, "format", "f", "yaml", "output format: yaml or json") + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output file path (default: stdout)") + return cmd +} + +func completionCmd() *cobra.Command { + var install bool + + cmd := &cobra.Command{ + Use: "completion [bash|zsh]", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for bash or zsh. + +To load completions in the current session: + source <(tomswall completion bash) + source <(tomswall completion zsh) + +To install completions permanently: + tomswall completion bash --install + tomswall completion zsh --install`, + ValidArgs: []string{"bash", "zsh"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root := cmd.Root() + switch args[0] { + case "bash": + if install { + path := "/etc/bash_completion.d/tomswall" + f, err := os.Create(path) + if err != nil { + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".local", "share", "bash-completion", "completions", "tomswall") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating completion directory: %w", err) + } + f, err = os.Create(path) + if err != nil { + return fmt.Errorf("creating completion file: %w", err) + } + } + defer f.Close() + if err := root.GenBashCompletionV2(f, true); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Bash completion installed to %s\n", path) + return nil + } + return root.GenBashCompletionV2(os.Stdout, true) + case "zsh": + if install { + path := "/usr/local/share/zsh/site-functions/_tomswall" + f, err := os.Create(path) + if err != nil { + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".local", "share", "zsh", "site-functions", "_tomswall") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating completion directory: %w", err) + } + f, err = os.Create(path) + if err != nil { + return fmt.Errorf("creating completion file: %w", err) + } + } + defer f.Close() + if err := root.GenZshCompletion(f); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Zsh completion installed to %s\n", path) + return nil + } + return root.GenZshCompletion(os.Stdout) + default: + return fmt.Errorf("unsupported shell: %s (use bash or zsh)", args[0]) + } + }, + } + cmd.Flags().BoolVar(&install, "install", false, "install completion file to system/user directory") + return cmd +} diff --git a/internal/config/accounting.go b/internal/config/accounting.go new file mode 100644 index 0000000..ddec534 --- /dev/null +++ b/internal/config/accounting.go @@ -0,0 +1,67 @@ +package config + +import "fmt" + +type AccountingAction string + +const ( + AccountingCount AccountingAction = "count" + AccountingDone AccountingAction = "done" + AccountingLog AccountingAction = "log" + AccountingNFLog AccountingAction = "nflog" +) + +type AccountingSection string + +const ( + AccountingSectionInput AccountingSection = "input" + AccountingSectionOutput AccountingSection = "output" + AccountingSectionForward AccountingSection = "forward" + AccountingSectionPrerouting AccountingSection = "prerouting" + AccountingSectionPostrouting AccountingSection = "postrouting" +) + +type AccountingRule struct { + Action AccountingAction `yaml:"action"` + Section AccountingSection `yaml:"section"` + + // Chain is an optional custom chain name. + Chain string `yaml:"chain,omitempty"` + + Source string `yaml:"source,omitempty"` + Dest string `yaml:"dest,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + Mark string `yaml:"mark,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +var validAccountingActions = map[AccountingAction]bool{ + AccountingCount: true, AccountingDone: true, + AccountingLog: true, AccountingNFLog: true, +} + +var validAccountingSections = map[AccountingSection]bool{ + AccountingSectionInput: true, AccountingSectionOutput: true, + AccountingSectionForward: true, AccountingSectionPrerouting: true, + AccountingSectionPostrouting: true, +} + +func (c *Config) validateAccounting() error { + for i, a := range c.Accounting { + if !validAccountingActions[a.Action] { + return fmt.Errorf("accounting[%d]: unknown action %q", i, a.Action) + } + if !validAccountingSections[a.Section] { + return fmt.Errorf("accounting[%d]: unknown section %q", i, a.Section) + } + + if a.Source == "" && a.Dest == "" { + return fmt.Errorf("accounting[%d]: source or dest required", i) + } + } + return nil +} diff --git a/internal/config/arprules.go b/internal/config/arprules.go new file mode 100644 index 0000000..cbb3d69 --- /dev/null +++ b/internal/config/arprules.go @@ -0,0 +1,63 @@ +package config + +import "fmt" + +type ArpAction string + +const ( + ArpAccept ArpAction = "accept" + ArpDrop ArpAction = "drop" + ArpSNAT ArpAction = "snat" + ArpDNAT ArpAction = "dnat" + ArpSMAT ArpAction = "smat" + ArpDMAT ArpAction = "dmat" +) + +type ArpRule struct { + // Action to take on matching ARP packets. + Action ArpAction `yaml:"action"` + + // ActionAddress is the IP address to rewrite to (required for snat/dnat). + ActionAddress string `yaml:"action_address,omitempty"` + + // ActionMAC is the MAC address to rewrite to (required for smat/dmat). + ActionMAC string `yaml:"action_mac,omitempty"` + + // Source zone/address spec. + Source string `yaml:"source,omitempty"` + + // Dest zone/address spec. + Dest string `yaml:"dest,omitempty"` + + // Opcode is the ARP operation code to match (e.g. 1=request, 2=reply). + Opcode int `yaml:"opcode,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validArpActions = map[ArpAction]bool{ + ArpAccept: true, ArpDrop: true, + ArpSNAT: true, ArpDNAT: true, + ArpSMAT: true, ArpDMAT: true, +} + +func (c *Config) validateArpRules() error { + for i, a := range c.ArpRules { + if !validArpActions[a.Action] { + return fmt.Errorf("arprules[%d]: unknown action %q", i, a.Action) + } + + if (a.Action == ArpSNAT || a.Action == ArpDNAT) && a.ActionAddress == "" { + return fmt.Errorf("arprules[%d]: action_address required for %s action", i, a.Action) + } + + if (a.Action == ArpSMAT || a.Action == ArpDMAT) && a.ActionMAC == "" { + return fmt.Errorf("arprules[%d]: action_mac required for %s action", i, a.Action) + } + + if a.Source == "" && a.Dest == "" { + return fmt.Errorf("arprules[%d]: source or dest required", i) + } + } + return nil +} diff --git a/internal/config/blrules.go b/internal/config/blrules.go new file mode 100644 index 0000000..b1fc8e3 --- /dev/null +++ b/internal/config/blrules.go @@ -0,0 +1,75 @@ +package config + +import "fmt" + +type BlruleAction string + +const ( + BlruleAccept BlruleAction = "accept" + BlruleWhitelist BlruleAction = "whitelist" + BlruleDrop BlruleAction = "drop" + BlruleReject BlruleAction = "reject" + BlruleLog BlruleAction = "log" + BlruleContinue BlruleAction = "continue" + BlruleNFQueue BlruleAction = "nfqueue" +) + +// BlruleRule defines a blacklist/whitelist rule. +// Processed before normal rules; ACCEPT/WHITELIST/CONTINUE exempt matching +// traffic from remaining blacklist rules. +type BlruleRule struct { + Action BlruleAction `yaml:"action"` + + Source string `yaml:"source"` + Dest string `yaml:"dest"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + Log string `yaml:"log,omitempty"` + + // NFQUEUE number (only for nfqueue action). + NFQueue int `yaml:"nfqueue,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validBlruleActions = map[BlruleAction]bool{ + BlruleAccept: true, BlruleWhitelist: true, + BlruleDrop: true, BlruleReject: true, + BlruleLog: true, BlruleContinue: true, + BlruleNFQueue: true, +} + +func (c *Config) validateBlrules() error { + for i, r := range c.Blrules { + if !validBlruleActions[r.Action] { + return fmt.Errorf("blrules[%d]: unknown action %q", i, r.Action) + } + + if r.Source == "" { + return fmt.Errorf("blrules[%d]: source required", i) + } + if r.Dest == "" { + return fmt.Errorf("blrules[%d]: dest required", i) + } + + if r.Source != "all" && r.Source != "any" && r.Source != "none" && + !hasPrefix(r.Source, "all!") && !hasPrefix(r.Source, "any!") { + srcZone := zoneFromSpec(r.Source) + if _, ok := c.Zones[srcZone]; !ok { + return fmt.Errorf("blrules[%d]: source zone %q not defined", i, srcZone) + } + } + + if r.Dest != "all" && r.Dest != "any" && r.Dest != "none" && + !hasPrefix(r.Dest, "all!") && !hasPrefix(r.Dest, "any!") { + dstZone := zoneFromSpec(r.Dest) + if _, ok := c.Zones[dstZone]; !ok { + return fmt.Errorf("blrules[%d]: dest zone %q not defined", i, dstZone) + } + } + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 9cfa93b..40fe1ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,29 +1,67 @@ package config import ( + "encoding/json" "fmt" "os" + "path/filepath" + "strings" "gopkg.in/yaml.v3" ) type Config struct { - Settings Settings `yaml:"settings"` - PortGroups map[string]PortGroup `yaml:"portgroups"` - Zones map[string]Zone `yaml:"zones"` - Interfaces []Interface `yaml:"interfaces"` - Hosts []Host `yaml:"hosts"` - Policy []Policy `yaml:"policy"` - Rules []Rule `yaml:"rules"` - SNAT []SNATRule `yaml:"snat"` + Settings Settings `yaml:"settings"` + Vars map[string]string `yaml:"vars,omitempty"` + PortGroups map[string]PortGroup `yaml:"portgroups"` + Zones map[string]Zone `yaml:"zones"` + Interfaces []Interface `yaml:"interfaces"` + Hosts []Host `yaml:"hosts"` + Policy []Policy `yaml:"policy"` + Rules []Rule `yaml:"rules"` + Blrules []BlruleRule `yaml:"blrules,omitempty"` + SNAT []SNATRule `yaml:"snat"` + StaticNAT []StaticNAT `yaml:"nat"` + Netmap []Netmap `yaml:"netmap"` + Providers []Provider `yaml:"providers"` + Conntrack []ConntrackRule `yaml:"conntrack,omitempty"` + Tunnels []Tunnel `yaml:"tunnels,omitempty"` + RoutingRules []RoutingRule `yaml:"rtrules,omitempty"` + StoppedRules []StoppedRule `yaml:"stoppedrules,omitempty"` + ProxyARP []ProxyARP `yaml:"proxyarp,omitempty"` + ProxyNDP []ProxyNDP `yaml:"proxyndp,omitempty"` + Routes []StaticRoute `yaml:"routes,omitempty"` + ArpRules []ArpRule `yaml:"arprules,omitempty"` + Accounting []AccountingRule `yaml:"accounting,omitempty"` + Mangle []MangleRule `yaml:"mangle,omitempty"` + Maclist []MaclistEntry `yaml:"maclist,omitempty"` + TCDevices []TCDevice `yaml:"tcdevices,omitempty"` + TCClasses []TCClass `yaml:"tcclasses,omitempty"` + TCFilters []TCFilter `yaml:"tcfilters,omitempty"` + TCInterfaces []TCInterface `yaml:"tcinterfaces,omitempty"` + TCPriorities []TCPriority `yaml:"tcpriority,omitempty"` + Secmarks []SecmarkRule `yaml:"secmarks,omitempty"` } +type AddressFamily string + +const ( + FamilyINET AddressFamily = "inet" + FamilyIP AddressFamily = "ip" + FamilyIP6 AddressFamily = "ip6" +) + type Settings struct { - IPForwarding bool `yaml:"ip_forwarding"` - LogLevel string `yaml:"log_level"` - TableName string `yaml:"table_name"` + AddressFamily AddressFamily `yaml:"address_family,omitempty"` + IPForwarding bool `yaml:"ip_forwarding"` + LogLevel string `yaml:"log_level"` + TableName string `yaml:"table_name"` + + // When true, auto-generate CONTINUE policies for sub-zones to their parent zones. + ImplicitContinue bool `yaml:"implicit_continue,omitempty"` } +// Load reads a config file in YAML or JSON format (detected by extension). func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { @@ -31,14 +69,32 @@ func Load(path string) (*Config, error) { } var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parsing config: %w", err) + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".json": + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing JSON config: %w", err) + } + default: + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing YAML config: %w", err) + } } cfg.applyDefaults() return &cfg, nil } +// ToYAML serializes the config to YAML bytes. +func (c *Config) ToYAML() ([]byte, error) { + return yaml.Marshal(c) +} + +// ToJSON serializes the config to indented JSON bytes. +func (c *Config) ToJSON() ([]byte, error) { + return json.MarshalIndent(c, "", " ") +} + func (c *Config) applyDefaults() { if c.Settings.TableName == "" { c.Settings.TableName = "tomswall" @@ -46,9 +102,26 @@ func (c *Config) applyDefaults() { if c.Settings.LogLevel == "" { c.Settings.LogLevel = "info" } + if c.Settings.AddressFamily == "" { + c.Settings.AddressFamily = FamilyINET + } +} + +var validAddressFamilies = map[AddressFamily]bool{ + FamilyINET: true, FamilyIP: true, FamilyIP6: true, +} + +func (c *Config) validateSettings() error { + if !validAddressFamilies[c.Settings.AddressFamily] { + return fmt.Errorf("unknown address_family %q (use inet, ip, or ip6)", c.Settings.AddressFamily) + } + return nil } func (c *Config) Validate() error { + if err := c.validateSettings(); err != nil { + return fmt.Errorf("settings: %w", err) + } if err := c.validateZones(); err != nil { return fmt.Errorf("zones: %w", err) } @@ -70,6 +143,72 @@ func (c *Config) Validate() error { if err := c.validateSNAT(); err != nil { return fmt.Errorf("snat: %w", err) } + if err := c.validateStaticNAT(); err != nil { + return fmt.Errorf("nat: %w", err) + } + if err := c.validateNetmap(); err != nil { + return fmt.Errorf("netmap: %w", err) + } + if err := c.validateProviders(); err != nil { + return fmt.Errorf("providers: %w", err) + } + if err := c.validateVars(); err != nil { + return fmt.Errorf("vars: %w", err) + } + if err := c.validateConntrack(); err != nil { + return fmt.Errorf("conntrack: %w", err) + } + if err := c.validateBlrules(); err != nil { + return fmt.Errorf("blrules: %w", err) + } + if err := c.validateTunnels(); err != nil { + return fmt.Errorf("tunnels: %w", err) + } + if err := c.validateRoutingRules(); err != nil { + return fmt.Errorf("rtrules: %w", err) + } + if err := c.validateStoppedRules(); err != nil { + return fmt.Errorf("stoppedrules: %w", err) + } + if err := c.validateProxyARP(); err != nil { + return fmt.Errorf("proxyarp: %w", err) + } + if err := c.validateProxyNDP(); err != nil { + return fmt.Errorf("proxyndp: %w", err) + } + if err := c.validateRoutes(); err != nil { + return fmt.Errorf("routes: %w", err) + } + if err := c.validateArpRules(); err != nil { + return fmt.Errorf("arprules: %w", err) + } + if err := c.validateAccounting(); err != nil { + return fmt.Errorf("accounting: %w", err) + } + if err := c.validateMangle(); err != nil { + return fmt.Errorf("mangle: %w", err) + } + if err := c.validateMaclist(); err != nil { + return fmt.Errorf("maclist: %w", err) + } + if err := c.validateTCDevices(); err != nil { + return fmt.Errorf("tcdevices: %w", err) + } + if err := c.validateTCClasses(); err != nil { + return fmt.Errorf("tcclasses: %w", err) + } + if err := c.validateTCFilters(); err != nil { + return fmt.Errorf("tcfilters: %w", err) + } + if err := c.validateTCInterfaces(); err != nil { + return fmt.Errorf("tcinterfaces: %w", err) + } + if err := c.validateTCPriority(); err != nil { + return fmt.Errorf("tcpriority: %w", err) + } + if err := c.validateSecmarks(); err != nil { + return fmt.Errorf("secmarks: %w", err) + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..1f08bc3 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,1009 @@ +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) + }) + } +} + diff --git a/internal/config/conntrack.go b/internal/config/conntrack.go new file mode 100644 index 0000000..527a00f --- /dev/null +++ b/internal/config/conntrack.go @@ -0,0 +1,86 @@ +package config + +import "fmt" + +type ConntrackAction string + +const ( + ConntrackNoTrack ConntrackAction = "notrack" + ConntrackHelper ConntrackAction = "helper" + ConntrackDrop ConntrackAction = "drop" + ConntrackLog ConntrackAction = "log" +) + +type ConntrackChain string + +const ( + ConntrackPrerouting ConntrackChain = "prerouting" + ConntrackOutput ConntrackChain = "output" + ConntrackBoth ConntrackChain = "both" +) + +type ConntrackRule struct { + // Action: notrack (bypass conntrack), helper (assign CT helper), drop (raw table drop), log. + Action ConntrackAction `yaml:"action"` + + // Source zone spec. Supports zone, zone:interface, zone:interface:address. + Source string `yaml:"source,omitempty"` + + // Dest zone spec. Same syntax as Source. + Dest string `yaml:"dest,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + // Chain to install the rule in: prerouting, output, or both. Default: prerouting. + Chain ConntrackChain `yaml:"chain,omitempty"` + + // CT helper name (for helper action): ftp, sip, tftp, irc, pptp, amanda, snmp, etc. + Helper string `yaml:"helper,omitempty"` + + // User/group match (only valid for output chain). + User string `yaml:"user,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validConntrackActions = map[ConntrackAction]bool{ + ConntrackNoTrack: true, ConntrackHelper: true, + ConntrackDrop: true, ConntrackLog: true, +} + +var validConntrackChains = map[ConntrackChain]bool{ + ConntrackPrerouting: true, ConntrackOutput: true, + ConntrackBoth: true, "": true, +} + +func (c *Config) validateConntrack() error { + for i, ct := range c.Conntrack { + if !validConntrackActions[ct.Action] { + return fmt.Errorf("conntrack[%d]: unknown action %q", i, ct.Action) + } + if !validConntrackChains[ct.Chain] { + return fmt.Errorf("conntrack[%d]: chain must be prerouting, output, or both", i) + } + + if ct.Action == ConntrackHelper && ct.Helper == "" { + return fmt.Errorf("conntrack[%d]: helper name required for helper action", i) + } + + if ct.Source == "" && ct.Dest == "" && ct.Action != ConntrackHelper { + return fmt.Errorf("conntrack[%d]: source or dest required", i) + } + + if ct.User != "" { + chain := ct.Chain + if chain == "" { + chain = ConntrackPrerouting + } + if chain == ConntrackPrerouting { + return fmt.Errorf("conntrack[%d]: user match only valid for output chain", i) + } + } + } + return nil +} diff --git a/internal/config/extras_test.go b/internal/config/extras_test.go new file mode 100644 index 0000000..9121d60 --- /dev/null +++ b/internal/config/extras_test.go @@ -0,0 +1,1444 @@ +package config + +import ( + "strings" + "testing" +) + +// --- 1. Conntrack --- + +func TestValidateConntrack(t *testing.T) { + tests := []struct { + name string + rules []ConntrackRule + wantErr string + }{ + { + name: "valid notrack rule", + rules: []ConntrackRule{ + {Action: ConntrackNoTrack, Source: "net", Proto: "udp"}, + }, + }, + { + name: "missing action", + rules: []ConntrackRule{ + {Source: "net"}, + }, + wantErr: "unknown action", + }, + { + name: "helper requires helper name", + rules: []ConntrackRule{ + {Action: ConntrackHelper, Source: "net"}, + }, + wantErr: "helper name required", + }, + { + name: "valid helper with name", + rules: []ConntrackRule{ + {Action: ConntrackHelper, Source: "net", Helper: "ftp"}, + }, + }, + { + name: "user requires output chain", + rules: []ConntrackRule{ + {Action: ConntrackNoTrack, Source: "net", User: "nobody"}, + }, + wantErr: "user match only valid for output chain", + }, + { + name: "user with output chain is valid", + rules: []ConntrackRule{ + {Action: ConntrackNoTrack, Source: "net", User: "nobody", Chain: ConntrackOutput}, + }, + }, + { + name: "source or dest required for non-helper", + rules: []ConntrackRule{ + {Action: ConntrackDrop}, + }, + wantErr: "source or dest required", + }, + { + name: "helper without source/dest is valid", + rules: []ConntrackRule{ + {Action: ConntrackHelper, Helper: "ftp", Proto: "tcp", Chain: ConntrackBoth}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Conntrack = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 2. Blrules --- + +func TestValidateBlrules(t *testing.T) { + tests := []struct { + name string + rules []BlruleRule + wantErr string + }{ + { + name: "valid rule", + rules: []BlruleRule{ + {Action: BlruleAccept, Source: "net", Dest: "loc"}, + }, + }, + { + name: "unknown action", + rules: []BlruleRule{ + {Action: "bogus", Source: "net", Dest: "loc"}, + }, + wantErr: "unknown action", + }, + { + name: "source zone not defined", + rules: []BlruleRule{ + {Action: BlruleDrop, Source: "nosuchzone", Dest: "loc"}, + }, + wantErr: `source zone "nosuchzone" not defined`, + }, + { + name: "source required", + rules: []BlruleRule{ + {Action: BlruleDrop, Dest: "loc"}, + }, + wantErr: "source required", + }, + { + name: "dest required", + rules: []BlruleRule{ + {Action: BlruleDrop, Source: "net"}, + }, + wantErr: "dest required", + }, + { + name: "source all is valid", + rules: []BlruleRule{ + {Action: BlruleDrop, Source: "all", Dest: "all"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Blrules = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 3. Tunnels --- + +func TestValidateTunnels(t *testing.T) { + tests := []struct { + name string + tunnels []Tunnel + wantErr string + }{ + { + name: "valid tunnel", + tunnels: []Tunnel{ + {Type: "ipsec", Zone: "net", Gateways: []string{"1.2.3.4"}}, + }, + }, + { + name: "zone not defined", + tunnels: []Tunnel{ + {Type: "gre", Zone: "nosuchzone", Gateways: []string{"1.2.3.4"}}, + }, + wantErr: `zone "nosuchzone" not defined`, + }, + { + name: "missing gateways", + tunnels: []Tunnel{ + {Type: "ipsec", Zone: "net"}, + }, + wantErr: "at least one gateway required", + }, + { + name: "unknown tunnel type", + tunnels: []Tunnel{ + {Type: "bogus", Zone: "net", Gateways: []string{"1.2.3.4"}}, + }, + wantErr: "unknown tunnel type", + }, + { + name: "tunnel type with suffix", + tunnels: []Tunnel{ + {Type: "ipsec:ah", Zone: "net", Gateways: []string{"1.2.3.4"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Tunnels = tt.tunnels + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 4. Rtrules --- + +func TestValidateRoutingRules(t *testing.T) { + withProvider := func() Config { + cfg := baseConfig() + cfg.Providers = []Provider{ + {Name: "isp1", Number: 1, Interface: "eth0"}, + } + return cfg + } + + tests := []struct { + name string + setup func() Config + rules []RoutingRule + wantErr string + }{ + { + name: "valid routing rule", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "isp1", Priority: 1000}, + }, + }, + { + name: "missing providers", + setup: baseConfig, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "isp1", Priority: 1000}, + }, + wantErr: "rtrules require providers", + }, + { + name: "provider not defined", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "nosuch", Priority: 1000}, + }, + wantErr: `provider "nosuch" not defined`, + }, + { + name: "priority below range", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "isp1", Priority: 999}, + }, + wantErr: "priority must be 1000-26999", + }, + { + name: "priority above range", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "isp1", Priority: 27000}, + }, + wantErr: "priority must be 1000-26999", + }, + { + name: "source or dest required", + setup: withProvider, + rules: []RoutingRule{ + {Provider: "isp1", Priority: 1000}, + }, + wantErr: "source or dest required", + }, + { + name: "provider required", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Priority: 1000}, + }, + wantErr: "provider required", + }, + { + name: "main provider always valid", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "main", Priority: 1000}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.setup() + cfg.RoutingRules = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 5. StoppedRules --- + +func TestValidateStoppedRules(t *testing.T) { + tests := []struct { + name string + rules []StoppedRule + wantErr string + }{ + { + name: "valid rule", + rules: []StoppedRule{ + {Action: StoppedAccept, Source: "eth0"}, + }, + }, + { + name: "unknown action", + rules: []StoppedRule{ + {Action: "bogus", Source: "eth0"}, + }, + wantErr: "unknown action", + }, + { + name: "missing source and dest", + rules: []StoppedRule{ + {Action: StoppedAccept}, + }, + wantErr: "source or dest required", + }, + { + name: "accept with source and dest is valid", + rules: []StoppedRule{ + {Action: StoppedAccept, Source: "eth0", Dest: "eth1"}, + }, + }, + { + name: "notrack with dest $FW is valid", + rules: []StoppedRule{ + {Action: StoppedNoTrack, Source: "eth0", Dest: "$FW"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.StoppedRules = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 6. Vars --- + +func TestValidateVars(t *testing.T) { + tests := []struct { + name string + vars map[string]string + wantErr string + }{ + { + name: "valid vars", + vars: map[string]string{"NET_IF": "eth0", "PORT": "8080"}, + }, + { + name: "empty var name", + vars: map[string]string{"": "value"}, + wantErr: "empty variable name", + }, + { + name: "invalid chars - space", + vars: map[string]string{"bad name": "value"}, + wantErr: "invalid variable name", + }, + { + name: "invalid chars - dollar", + vars: map[string]string{"$var": "value"}, + wantErr: "invalid variable name", + }, + { + name: "invalid chars - braces", + vars: map[string]string{"{var}": "value"}, + wantErr: "invalid variable name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Vars = tt.vars + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 7. NAT (static) --- + +func TestValidateStaticNAT(t *testing.T) { + tests := []struct { + name string + nat []StaticNAT + wantErr string + }{ + { + name: "valid nat", + nat: []StaticNAT{ + {External: "1.2.3.4", Interface: "eth0", Internal: "10.0.0.1"}, + }, + }, + { + name: "external required", + nat: []StaticNAT{ + {Interface: "eth0", Internal: "10.0.0.1"}, + }, + wantErr: "external address required", + }, + { + name: "external must be IP", + nat: []StaticNAT{ + {External: "example.com", Interface: "eth0", Internal: "10.0.0.1"}, + }, + wantErr: "external must be an IP address", + }, + { + name: "interface required", + nat: []StaticNAT{ + {External: "1.2.3.4", Internal: "10.0.0.1"}, + }, + wantErr: "interface required", + }, + { + name: "internal required", + nat: []StaticNAT{ + {External: "1.2.3.4", Interface: "eth0"}, + }, + wantErr: "internal address required", + }, + { + name: "internal must be IP", + nat: []StaticNAT{ + {External: "1.2.3.4", Interface: "eth0", Internal: "server.local"}, + }, + wantErr: "internal must be an IP address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.StaticNAT = tt.nat + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 8. Netmap --- + +func TestValidateNetmap(t *testing.T) { + tests := []struct { + name string + netmap []Netmap + wantErr string + }{ + { + name: "valid netmap", + netmap: []Netmap{ + {Type: NetmapDNAT, Net1: "192.168.1.0/24", Interface: "eth0", Net2: "10.0.0.0/24"}, + }, + }, + { + name: "invalid CIDR net1", + netmap: []Netmap{ + {Type: NetmapDNAT, Net1: "notacidr", Interface: "eth0", Net2: "10.0.0.0/24"}, + }, + wantErr: "net1 must be CIDR format", + }, + { + name: "invalid CIDR net2", + netmap: []Netmap{ + {Type: NetmapSNAT, Net1: "192.168.1.0/24", Interface: "eth0", Net2: "notacidr"}, + }, + wantErr: "net2 must be CIDR format", + }, + { + name: "missing interface", + netmap: []Netmap{ + {Type: NetmapDNAT, Net1: "192.168.1.0/24", Net2: "10.0.0.0/24"}, + }, + wantErr: "interface required", + }, + { + name: "interface not defined", + netmap: []Netmap{ + {Type: NetmapDNAT, Net1: "192.168.1.0/24", Interface: "eth99", Net2: "10.0.0.0/24"}, + }, + wantErr: `interface "eth99" not defined`, + }, + { + name: "invalid type", + netmap: []Netmap{ + {Type: "bogus", Net1: "192.168.1.0/24", Interface: "eth0", Net2: "10.0.0.0/24"}, + }, + wantErr: "type must be dnat or snat", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Netmap = tt.netmap + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 9. Providers --- + +func TestValidateProviders(t *testing.T) { + tests := []struct { + name string + providers []Provider + wantErr string + }{ + { + name: "valid provider", + providers: []Provider{ + {Name: "isp1", Number: 1, Interface: "eth0"}, + }, + }, + { + name: "duplicate name", + providers: []Provider{ + {Name: "isp1", Number: 1, Interface: "eth0"}, + {Name: "isp1", Number: 2, Interface: "eth1"}, + }, + wantErr: `duplicate name "isp1"`, + }, + { + name: "reserved name local", + providers: []Provider{ + {Name: "local", Number: 1, Interface: "eth0"}, + }, + wantErr: `"local" is a reserved name`, + }, + { + name: "reserved name main", + providers: []Provider{ + {Name: "main", Number: 1, Interface: "eth0"}, + }, + wantErr: `"main" is a reserved name`, + }, + { + name: "number below range", + providers: []Provider{ + {Name: "isp1", Number: 0, Interface: "eth0"}, + }, + wantErr: "number must be between 1 and 252", + }, + { + name: "number above range", + providers: []Provider{ + {Name: "isp1", Number: 253, Interface: "eth0"}, + }, + wantErr: "number must be between 1 and 252", + }, + { + name: "duplicate number", + providers: []Provider{ + {Name: "isp1", Number: 1, Interface: "eth0"}, + {Name: "isp2", Number: 1, Interface: "eth1"}, + }, + wantErr: "number 1 already used", + }, + { + name: "interface required", + providers: []Provider{ + {Name: "isp1", Number: 1}, + }, + wantErr: "interface required", + }, + { + name: "name required", + providers: []Provider{ + {Number: 1, Interface: "eth0"}, + }, + wantErr: "name required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Providers = tt.providers + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 10. Accounting --- + +func TestValidateAccounting(t *testing.T) { + tests := []struct { + name string + rules []AccountingRule + wantErr string + }{ + { + name: "valid rule", + rules: []AccountingRule{ + {Action: AccountingCount, Section: AccountingSectionForward, Source: "net"}, + }, + }, + { + name: "source or dest required", + rules: []AccountingRule{ + {Action: AccountingCount, Section: AccountingSectionForward}, + }, + wantErr: "source or dest required", + }, + { + name: "unknown action", + rules: []AccountingRule{ + {Action: "bogus", Section: AccountingSectionForward, Source: "net"}, + }, + wantErr: "unknown action", + }, + { + name: "unknown section", + rules: []AccountingRule{ + {Action: AccountingCount, Section: "bogus", Source: "net"}, + }, + wantErr: "unknown section", + }, + { + name: "dest only is valid", + rules: []AccountingRule{ + {Action: AccountingDone, Section: AccountingSectionInput, Dest: "loc"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Accounting = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 11. Mangle --- + +func TestValidateMangle(t *testing.T) { + tests := []struct { + name string + rules []MangleRule + wantErr string + }{ + { + name: "valid rule", + rules: []MangleRule{ + {Action: MangleMark, Chain: ManglePrerouting, MarkValue: "0x1"}, + }, + }, + { + name: "mark_value required for mark action", + rules: []MangleRule{ + {Action: MangleMark, Chain: ManglePrerouting}, + }, + wantErr: "mark_value required for mark action", + }, + { + name: "mark_value required for connmark action", + rules: []MangleRule{ + {Action: MangleConnMark, Chain: ManglePrerouting}, + }, + wantErr: "mark_value required for connmark action", + }, + { + name: "mark_value required for classify action", + rules: []MangleRule{ + {Action: MangleClassify, Chain: MangleForward}, + }, + wantErr: "mark_value required for classify action", + }, + { + name: "unknown action", + rules: []MangleRule{ + {Action: "bogus", Chain: ManglePrerouting}, + }, + wantErr: "unknown action", + }, + { + name: "unknown chain", + rules: []MangleRule{ + {Action: MangleDrop, Chain: "bogus"}, + }, + wantErr: "unknown chain", + }, + { + name: "log action without mark_value is valid", + rules: []MangleRule{ + {Action: MangleLog, Chain: MangleInput}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Mangle = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 12. Maclist --- + +func TestValidateMaclist(t *testing.T) { + tests := []struct { + name string + entries []MaclistEntry + wantErr string + }{ + { + name: "valid entry with mac", + entries: []MaclistEntry{ + {Action: MaclistAccept, Interface: "eth0", MAC: "00:11:22:33:44:55"}, + }, + }, + { + name: "valid entry with addresses", + entries: []MaclistEntry{ + {Action: MaclistDrop, Interface: "eth0", Addresses: []string{"10.0.0.1"}}, + }, + }, + { + name: "mac or addresses required", + entries: []MaclistEntry{ + {Action: MaclistAccept, Interface: "eth0"}, + }, + wantErr: "mac or addresses required", + }, + { + name: "interface required", + entries: []MaclistEntry{ + {Action: MaclistAccept, MAC: "00:11:22:33:44:55"}, + }, + wantErr: "interface required", + }, + { + name: "unknown action", + entries: []MaclistEntry{ + {Action: "bogus", Interface: "eth0", MAC: "00:11:22:33:44:55"}, + }, + wantErr: "unknown action", + }, + { + name: "interface not defined", + entries: []MaclistEntry{ + {Action: MaclistAccept, Interface: "eth99", MAC: "00:11:22:33:44:55"}, + }, + wantErr: `interface "eth99" not defined`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Maclist = tt.entries + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 13. TC --- + +func TestValidateTCDevices(t *testing.T) { + tests := []struct { + name string + devices []TCDevice + wantErr string + }{ + { + name: "valid device", + devices: []TCDevice{ + {Interface: "eth0", OutBandwidth: "10mbit"}, + }, + }, + { + name: "interface required", + devices: []TCDevice{ + {OutBandwidth: "10mbit"}, + }, + wantErr: "interface required", + }, + { + name: "out_bandwidth required", + devices: []TCDevice{ + {Interface: "eth0"}, + }, + wantErr: "out_bandwidth required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCDevices = tt.devices + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +func TestValidateTCClasses(t *testing.T) { + tests := []struct { + name string + classes []TCClass + wantErr string + }{ + { + name: "valid class", + classes: []TCClass{ + {Interface: "eth0:1", Rate: "1mbit"}, + }, + }, + { + name: "interface required", + classes: []TCClass{ + {Rate: "1mbit"}, + }, + wantErr: "interface required", + }, + { + name: "rate required", + classes: []TCClass{ + {Interface: "eth0:1"}, + }, + wantErr: "rate required", + }, + { + name: "mark out of range", + classes: []TCClass{ + {Interface: "eth0:1", Rate: "1mbit", Mark: 256}, + }, + wantErr: "mark must be 1-255", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCClasses = tt.classes + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +func TestValidateTCFilters(t *testing.T) { + tests := []struct { + name string + filters []TCFilter + wantErr string + }{ + { + name: "valid filter", + filters: []TCFilter{ + {Class: "eth0:1", Source: "10.0.0.0/8"}, + }, + }, + { + name: "class required", + filters: []TCFilter{ + {Source: "10.0.0.0/8"}, + }, + wantErr: "class required", + }, + { + name: "source or dest required", + filters: []TCFilter{ + {Class: "eth0:1"}, + }, + wantErr: "source or dest required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCFilters = tt.filters + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +func TestValidateTCInterfaces(t *testing.T) { + tests := []struct { + name string + interfaces []TCInterface + wantErr string + }{ + { + name: "valid interface", + interfaces: []TCInterface{ + {Interface: "eth0"}, + }, + }, + { + name: "interface required", + interfaces: []TCInterface{ + {Type: "external"}, + }, + wantErr: "interface required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCInterfaces = tt.interfaces + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +func TestValidateTCPriority(t *testing.T) { + tests := []struct { + name string + priorities []TCPriority + wantErr string + }{ + { + name: "valid band 1", + priorities: []TCPriority{ + {Band: 1}, + }, + }, + { + name: "valid band 3", + priorities: []TCPriority{ + {Band: 3}, + }, + }, + { + name: "band below range", + priorities: []TCPriority{ + {Band: 0}, + }, + wantErr: "band must be 1, 2, or 3", + }, + { + name: "band above range", + priorities: []TCPriority{ + {Band: 4}, + }, + wantErr: "band must be 1, 2, or 3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCPriorities = tt.priorities + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 14. ProxyARP --- + +func TestValidateProxyARP(t *testing.T) { + tests := []struct { + name string + entries []ProxyARP + wantErr string + }{ + { + name: "valid entry", + entries: []ProxyARP{ + {Address: "1.2.3.4", Interface: "eth1", External: "eth0"}, + }, + }, + { + name: "address required", + entries: []ProxyARP{ + {Interface: "eth1", External: "eth0"}, + }, + wantErr: "address required", + }, + { + name: "external required", + entries: []ProxyARP{ + {Address: "1.2.3.4", Interface: "eth1"}, + }, + wantErr: "external required", + }, + { + name: "interface required unless haveroute", + entries: []ProxyARP{ + {Address: "1.2.3.4", External: "eth0"}, + }, + wantErr: "interface required unless haveroute", + }, + { + name: "haveroute skips interface requirement", + entries: []ProxyARP{ + {Address: "1.2.3.4", External: "eth0", HaveRoute: true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.ProxyARP = tt.entries + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 15. Routes --- + +func TestValidateRoutes(t *testing.T) { + tests := []struct { + name string + routes []StaticRoute + wantErr string + }{ + { + name: "valid route", + routes: []StaticRoute{ + {Provider: "main", Dest: "10.0.0.0/8", Gateway: "192.168.1.1"}, + }, + }, + { + name: "provider required", + routes: []StaticRoute{ + {Dest: "10.0.0.0/8", Gateway: "192.168.1.1"}, + }, + wantErr: "provider required", + }, + { + name: "dest required", + routes: []StaticRoute{ + {Provider: "main", Gateway: "192.168.1.1"}, + }, + wantErr: "dest required", + }, + { + name: "device not allowed with blackhole", + routes: []StaticRoute{ + {Provider: "main", Dest: "10.0.0.0/8", Gateway: "blackhole", Device: "eth0"}, + }, + wantErr: "device not allowed with blackhole gateway", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Routes = tt.routes + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 16. ArpRules --- + +func TestValidateArpRules(t *testing.T) { + tests := []struct { + name string + rules []ArpRule + wantErr string + }{ + { + name: "valid rule", + rules: []ArpRule{ + {Action: ArpAccept, Source: "net"}, + }, + }, + { + name: "action_address required for snat", + rules: []ArpRule{ + {Action: ArpSNAT, Source: "net"}, + }, + wantErr: "action_address required for snat action", + }, + { + name: "action_address required for dnat", + rules: []ArpRule{ + {Action: ArpDNAT, Source: "net"}, + }, + wantErr: "action_address required for dnat action", + }, + { + name: "action_mac required for smat", + rules: []ArpRule{ + {Action: ArpSMAT, Source: "net"}, + }, + wantErr: "action_mac required for smat action", + }, + { + name: "unknown action", + rules: []ArpRule{ + {Action: "bogus", Source: "net"}, + }, + wantErr: "unknown action", + }, + { + name: "source or dest required", + rules: []ArpRule{ + {Action: ArpDrop}, + }, + wantErr: "source or dest required", + }, + { + name: "valid snat with action_address", + rules: []ArpRule{ + {Action: ArpSNAT, Source: "net", ActionAddress: "1.2.3.4"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.ArpRules = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 17. Secmarks --- + +func TestValidateSecmarks(t *testing.T) { + tests := []struct { + name string + rules []SecmarkRule + wantErr string + }{ + { + name: "valid secmark", + rules: []SecmarkRule{ + {Secmark: "system_u:object_r:httpd_t:s0", Chain: "P"}, + }, + }, + { + name: "secmark required", + rules: []SecmarkRule{ + {Chain: "P"}, + }, + wantErr: "secmark required", + }, + { + name: "chain required", + rules: []SecmarkRule{ + {Secmark: "system_u:object_r:httpd_t:s0"}, + }, + wantErr: "chain required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Secmarks = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 18. Zone nesting --- + +func TestResolveNesting(t *testing.T) { + t.Run("simple parent-child hierarchy", func(t *testing.T) { + cfg := Config{ + Zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "dmz": {Type: ZoneIP, Parents: []string{"net"}}, + }, + } + order, err := cfg.ResolveNesting() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // dmz (child) must appear before net (parent) or at least both must appear. + // The key constraint: all zones should be present. + if len(order) != 3 { + t.Fatalf("expected 3 zones in order, got %d: %v", len(order), order) + } + }) + + t.Run("circular detection", func(t *testing.T) { + // The algorithm requires all siblings (children of the same parent) + // to be resolved before any of them can proceed. Two children of + // the same non-firewall parent create a deadlock that is reported + // as a circular nesting error. + cfg := Config{ + Zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "parent": {Type: ZoneIP}, + "child1": {Type: ZoneIP, Parents: []string{"parent"}}, + "child2": {Type: ZoneIP, Parents: []string{"parent"}}, + }, + } + _, err := cfg.ResolveNesting() + if err == nil { + t.Fatal("expected circular nesting error") + } + if !strings.Contains(err.Error(), "circular") { + t.Fatalf("expected circular error, got: %v", err) + } + }) +} + +func TestChildZones(t *testing.T) { + cfg := Config{ + Zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "dmz": {Type: ZoneIP, Parents: []string{"net"}}, + "vpn": {Type: ZoneIP, Parents: []string{"net"}}, + "loc": {Type: ZoneIP}, + }, + } + children := cfg.ChildZones("net") + if len(children) != 2 { + t.Fatalf("expected 2 children of net, got %d: %v", len(children), children) + } + // Check both dmz and vpn are present. + found := map[string]bool{} + for _, c := range children { + found[c] = true + } + if !found["dmz"] || !found["vpn"] { + t.Fatalf("expected dmz and vpn as children, got: %v", children) + } + + // loc has no children. + locChildren := cfg.ChildZones("loc") + if len(locChildren) != 0 { + t.Fatalf("expected 0 children of loc, got %d", len(locChildren)) + } +} + +func TestIsSubZone(t *testing.T) { + cfg := Config{ + Zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "dmz": {Type: ZoneIP, Parents: []string{"net"}}, + "web": {Type: ZoneIP, Parents: []string{"dmz"}}, + }, + } + + tests := []struct { + child, parent string + want bool + }{ + {"dmz", "net", true}, + {"web", "dmz", true}, + {"web", "net", true}, // transitive + {"net", "dmz", false}, // reverse + {"net", "net", false}, // self + {"nosuch", "net", false}, // non-existent + } + + for _, tt := range tests { + t.Run(tt.child+"->"+tt.parent, func(t *testing.T) { + got := cfg.IsSubZone(tt.child, tt.parent) + if got != tt.want { + t.Fatalf("IsSubZone(%q, %q) = %v, want %v", tt.child, tt.parent, got, tt.want) + } + }) + } +} + +// --- 19. Names --- + +func TestValidateName(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + {"valid simple", "net", ""}, + {"valid with underscore", "my_zone", ""}, + {"valid with digits", "zone1", ""}, + {"empty name", "", "empty"}, + {"starts with digit", "1zone", "must start with a letter"}, + {"contains dash", "my-zone", "invalid character"}, + {"contains space", "my zone", "invalid character"}, + {"contains dot", "my.zone", "invalid character"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateName(tt.input, "test") + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 20. Params --- + +func TestSubstituteVars(t *testing.T) { + tests := []struct { + name string + input string + vars map[string]string + want string + }{ + { + name: "braced substitution", + input: "${NET_IF}", + vars: map[string]string{"NET_IF": "eth0"}, + want: "eth0", + }, + { + name: "unbraced substitution", + input: "$NET_IF", + vars: map[string]string{"NET_IF": "eth0"}, + want: "eth0", + }, + { + name: "no vars", + input: "no substitution", + vars: nil, + want: "no substitution", + }, + { + name: "no dollar sign", + input: "plain text", + vars: map[string]string{"foo": "bar"}, + want: "plain text", + }, + { + name: "multiple vars", + input: "${A}:${B}", + vars: map[string]string{"A": "1", "B": "2"}, + want: "1:2", + }, + { + name: "undefined var stays", + input: "${UNDEF}", + vars: map[string]string{"OTHER": "val"}, + want: "${UNDEF}", + }, + { + name: "empty vars map", + input: "$foo", + vars: map[string]string{}, + want: "$foo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SubstituteVars(tt.input, tt.vars) + if got != tt.want { + t.Fatalf("SubstituteVars(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// --- 22. ProxyNDP --- + +func TestValidateProxyNDP(t *testing.T) { + tests := []struct { + name string + entries []ProxyNDP + wantErr string + }{ + { + name: "valid entry", + entries: []ProxyNDP{ + {Address: "fd10::100", Interface: "eth0", External: "eth1"}, + }, + }, + { + name: "address required", + entries: []ProxyNDP{ + {Interface: "eth0", External: "eth1"}, + }, + wantErr: "address required", + }, + { + name: "external required", + entries: []ProxyNDP{ + {Address: "fd10::100", Interface: "eth0"}, + }, + wantErr: "external required", + }, + { + name: "interface required unless haveroute", + entries: []ProxyNDP{ + {Address: "fd10::100", External: "eth1"}, + }, + wantErr: "interface required unless haveroute", + }, + { + name: "haveroute skips interface requirement", + entries: []ProxyNDP{ + {Address: "fd10::100", External: "eth1", HaveRoute: true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.ProxyNDP = tt.entries + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 23. Settings --- + +func TestValidateSettings(t *testing.T) { + tests := []struct { + name string + family AddressFamily + wantErr string + }{ + {"inet is valid", FamilyINET, ""}, + {"ip is valid", FamilyIP, ""}, + {"ip6 is valid", FamilyIP6, ""}, + {"empty is invalid", "", "unknown address_family"}, + {"bogus is invalid", "bogus", "unknown address_family"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Settings.AddressFamily = tt.family + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + diff --git a/internal/config/hosts.go b/internal/config/hosts.go index 7863c3b..f9847dc 100644 --- a/internal/config/hosts.go +++ b/internal/config/hosts.go @@ -3,25 +3,54 @@ package config import "fmt" type Host struct { - Zone string `yaml:"zone"` - Interface string `yaml:"interface"` - Addresses []string `yaml:"addresses"` - Options []string `yaml:"options,omitempty"` + Zone string `yaml:"zone"` + Interface string `yaml:"interface"` + Addresses []string `yaml:"addresses"` + Exclusions []string `yaml:"exclusions,omitempty"` + Dynamic bool `yaml:"dynamic,omitempty"` + Options HostOptions `yaml:"options,omitempty"` +} + +type HostOptions struct { + Broadcast bool `yaml:"broadcast,omitempty"` + DestOnly bool `yaml:"destonly,omitempty"` + IPSec bool `yaml:"ipsec,omitempty"` + MSS int `yaml:"mss,omitempty"` + NoSmurfs bool `yaml:"nosmurfs,omitempty"` + RouteBack bool `yaml:"routeback,omitempty"` + TCPFlags bool `yaml:"tcpflags,omitempty"` } func (c *Config) validateHosts() error { + fwZone := c.FirewallZone() + for i, h := range c.Hosts { if h.Zone == "" { return fmt.Errorf("host[%d]: zone required", i) } + if h.Zone == fwZone { + return fmt.Errorf("host[%d]: firewall zone must not be listed in hosts", i) + } if _, ok := c.Zones[h.Zone]; !ok { return fmt.Errorf("host[%d]: zone %q not defined", i, h.Zone) } if h.Interface == "" { return fmt.Errorf("host[%d]: interface required", i) } - if len(h.Addresses) == 0 { - return fmt.Errorf("host[%d]: at least one address required", i) + + ifaceFound := false + for _, iface := range c.Interfaces { + if iface.Interface == h.Interface || iface.PhysicalName() == h.Interface { + ifaceFound = true + break + } + } + if !ifaceFound { + return fmt.Errorf("host[%d]: interface %q not defined in interfaces", i, h.Interface) + } + + if !h.Dynamic && len(h.Addresses) == 0 { + return fmt.Errorf("host[%d]: at least one address required (or set dynamic: true)", i) } } return nil diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index 4789b58..c4838df 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -1,34 +1,94 @@ package config -import "fmt" +import ( + "fmt" + "strings" +) type Interface struct { - Zone string `yaml:"zone"` + Zone string `yaml:"zone,omitempty"` Interface string `yaml:"interface"` Options InterfaceOptions `yaml:"options,omitempty"` } type InterfaceOptions struct { + // Rule generation options DHCP bool `yaml:"dhcp,omitempty"` - TCPFlags bool `yaml:"tcpflags,omitempty"` + TCPFlags *bool `yaml:"tcpflags,omitempty"` NoSmurfs bool `yaml:"nosmurfs,omitempty"` - RouteBack bool `yaml:"routeback,omitempty"` + RouteBack *bool `yaml:"routeback,omitempty"` Bridge bool `yaml:"bridge,omitempty"` - Optional bool `yaml:"optional,omitempty"` + DestOnly bool `yaml:"destonly,omitempty"` + Unmanaged bool `yaml:"unmanaged,omitempty"` + Upnp bool `yaml:"upnp,omitempty"` + + // Startup behavior + Optional bool `yaml:"optional,omitempty"` + Required bool `yaml:"required,omitempty"` + Wait int `yaml:"wait,omitempty"` + + // Logical-to-physical mapping + Physical string `yaml:"physical,omitempty"` + + // TCP MSS clamping for forwarded SYN packets + MSS int `yaml:"mss,omitempty"` + + // Limit zone to specific networks + Nets []string `yaml:"nets,omitempty"` + + // Sysctl adjustments (applied on startup) + RouteFilter *int `yaml:"routefilter,omitempty"` + LogMartians *bool `yaml:"logmartians,omitempty"` + ArpFilter *bool `yaml:"arp_filter,omitempty"` + ArpIgnore *int `yaml:"arp_ignore,omitempty"` + ProxyArp *bool `yaml:"proxyarp,omitempty"` + SourceRoute *bool `yaml:"sourceroute,omitempty"` + + // IPv6: controls acceptance of Router Advertisements (0/1/2) + AcceptRA *int `yaml:"accept_ra,omitempty"` +} + +// PhysicalName returns the actual interface name (physical if set, else logical). +func (iface *Interface) PhysicalName() string { + if iface.Options.Physical != "" { + return iface.Options.Physical + } + return iface.Interface +} + +// IsWildcard returns true if the interface matches multiple devices (e.g. "ppp+"). +func (iface *Interface) IsWildcard() bool { + return strings.HasSuffix(iface.PhysicalName(), "+") } func (c *Config) validateInterfaces() error { seen := make(map[string]bool) + fwZone := c.FirewallZone() + for i, iface := range c.Interfaces { if iface.Interface == "" { return fmt.Errorf("interface[%d]: interface name required", i) } - if iface.Zone == "" { - return fmt.Errorf("interface[%d] %q: zone required", i, iface.Interface) + if strings.Contains(iface.Interface, ":") { + return fmt.Errorf("interface[%d] %q: virtual interfaces (e.g. eth0:0) not supported; use the physical option instead", i, iface.Interface) } - if _, ok := c.Zones[iface.Zone]; !ok { - return fmt.Errorf("interface[%d] %q: zone %q not defined", i, iface.Interface, iface.Zone) + + if iface.Zone != "" { + if iface.Zone == fwZone { + return fmt.Errorf("interface[%d] %q: firewall zone must not be listed in interfaces", i, iface.Interface) + } + if _, ok := c.Zones[iface.Zone]; !ok { + return fmt.Errorf("interface[%d] %q: zone %q not defined", i, iface.Interface, iface.Zone) + } } + + if iface.Options.Unmanaged && iface.Zone != "" { + return fmt.Errorf("interface[%d] %q: unmanaged interfaces must have an empty zone", i, iface.Interface) + } + if iface.Options.Optional && iface.Options.Required { + return fmt.Errorf("interface[%d] %q: optional and required are mutually exclusive", i, iface.Interface) + } + if seen[iface.Interface] { return fmt.Errorf("interface[%d]: duplicate interface %q", i, iface.Interface) } diff --git a/internal/config/maclist.go b/internal/config/maclist.go new file mode 100644 index 0000000..a31dc0a --- /dev/null +++ b/internal/config/maclist.go @@ -0,0 +1,51 @@ +package config + +import "fmt" + +type MaclistAction string + +const ( + MaclistAccept MaclistAction = "accept" + MaclistDrop MaclistAction = "drop" + MaclistReject MaclistAction = "reject" +) + +type MaclistEntry struct { + Action MaclistAction `yaml:"action"` + Interface string `yaml:"interface"` + MAC string `yaml:"mac,omitempty"` + Addresses []string `yaml:"addresses,omitempty"` + Log string `yaml:"log,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +var validMaclistActions = map[MaclistAction]bool{ + MaclistAccept: true, MaclistDrop: true, MaclistReject: true, +} + +func (c *Config) validateMaclist() error { + // Build a set of configured interface names for lookup. + ifaceSet := make(map[string]bool, len(c.Interfaces)) + for _, iface := range c.Interfaces { + ifaceSet[iface.Interface] = true + } + + for i, m := range c.Maclist { + if !validMaclistActions[m.Action] { + return fmt.Errorf("maclist[%d]: unknown action %q", i, m.Action) + } + + if m.Interface == "" { + return fmt.Errorf("maclist[%d]: interface required", i) + } + + if m.MAC == "" && len(m.Addresses) == 0 { + return fmt.Errorf("maclist[%d]: mac or addresses required", i) + } + + if !ifaceSet[m.Interface] { + return fmt.Errorf("maclist[%d]: interface %q not defined in config", i, m.Interface) + } + } + return nil +} diff --git a/internal/config/mangle.go b/internal/config/mangle.go new file mode 100644 index 0000000..214eb5c --- /dev/null +++ b/internal/config/mangle.go @@ -0,0 +1,115 @@ +package config + +import "fmt" + +type MangleAction string + +const ( + MangleMark MangleAction = "mark" + MangleConnMark MangleAction = "connmark" + MangleClassify MangleAction = "classify" + MangleDSCP MangleAction = "dscp" + MangleTOS MangleAction = "tos" + MangleTProxy MangleAction = "tproxy" + MangleSave MangleAction = "save" + MangleRestore MangleAction = "restore" + MangleContinue MangleAction = "continue" + MangleDrop MangleAction = "drop" + MangleLog MangleAction = "log" + MangleNFLog MangleAction = "nflog" + MangleECN MangleAction = "ecn" + MangleTCPMSS MangleAction = "tcpmss" + MangleChecksum MangleAction = "checksum" + MangleInline MangleAction = "inline" +) + +type MangleChain string + +const ( + ManglePrerouting MangleChain = "prerouting" + MangleForward MangleChain = "forward" + ManglePostrouting MangleChain = "postrouting" + MangleInput MangleChain = "input" + MangleOutput MangleChain = "output" +) + +type MangleRule struct { + Action MangleAction `yaml:"action"` + Chain MangleChain `yaml:"chain"` + + // MarkValue is the value to set (required for mark, connmark, classify, dscp, tos actions). + MarkValue string `yaml:"mark_value,omitempty"` + + Source string `yaml:"source,omitempty"` + Dest string `yaml:"dest,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + // User/group match (only valid for output chain). + User string `yaml:"user,omitempty"` + + // Packet or connection mark test. Format: [!]value[/mask][:C] + Mark string `yaml:"mark,omitempty"` + + // Packet length match. + Length string `yaml:"length,omitempty"` + + // TOS field match. + TOS string `yaml:"tos,omitempty"` + + // Conntrack helper match. + Helper string `yaml:"helper,omitempty"` + + // Match probability (0.0 to 1.0). + Probability float64 `yaml:"probability,omitempty"` + + // DSCP field match. + DSCP string `yaml:"dscp,omitempty"` + + // Connection state match. + State string `yaml:"state,omitempty"` + + // Time-based restrictions. + Time *TimeSpec `yaml:"time,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validMangleActions = map[MangleAction]bool{ + MangleMark: true, MangleConnMark: true, MangleClassify: true, + MangleDSCP: true, MangleTOS: true, MangleTProxy: true, + MangleSave: true, MangleRestore: true, MangleContinue: true, + MangleDrop: true, MangleLog: true, MangleNFLog: true, + MangleECN: true, MangleTCPMSS: true, MangleChecksum: true, + MangleInline: true, +} + +var validMangleChains = map[MangleChain]bool{ + ManglePrerouting: true, MangleForward: true, + ManglePostrouting: true, MangleInput: true, + MangleOutput: true, +} + +// markValueRequiredActions lists actions that require a mark_value. +var markValueRequiredActions = map[MangleAction]bool{ + MangleMark: true, MangleConnMark: true, MangleClassify: true, + MangleDSCP: true, MangleTOS: true, +} + +func (c *Config) validateMangle() error { + for i, m := range c.Mangle { + if !validMangleActions[m.Action] { + return fmt.Errorf("mangle[%d]: unknown action %q", i, m.Action) + } + if !validMangleChains[m.Chain] { + return fmt.Errorf("mangle[%d]: unknown chain %q", i, m.Chain) + } + + if markValueRequiredActions[m.Action] && m.MarkValue == "" { + return fmt.Errorf("mangle[%d]: mark_value required for %s action", i, m.Action) + } + } + return nil +} diff --git a/internal/config/names.go b/internal/config/names.go new file mode 100644 index 0000000..3e9fe3d --- /dev/null +++ b/internal/config/names.go @@ -0,0 +1,55 @@ +package config + +import ( + "fmt" + "strings" + "unicode" +) + +// ValidateName checks that a name follows shorewall naming conventions: +// starts with a letter, composed of letters, digits, and underscores. +func ValidateName(name, kind string) error { + if len(name) == 0 { + return fmt.Errorf("%s name is empty", kind) + } + if !unicode.IsLetter(rune(name[0])) { + return fmt.Errorf("%s name %q must start with a letter", kind, name) + } + for _, r := range name { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' { + return fmt.Errorf("%s name %q contains invalid character %q", kind, name, r) + } + } + return nil +} + +// ValidateInterfaceRef checks that an interface reference is valid. +// Strips any @suffix (e.g. "sit1@NONE" -> "sit1"), allows trailing + for wildcards. +func ValidateInterfaceRef(name string) string { + if idx := strings.IndexByte(name, '@'); idx >= 0 { + name = name[:idx] + } + return name +} + +// IsWildcardInterface returns true if the interface name is a wildcard (ends with +). +func IsWildcardInterface(name string) bool { + return strings.HasSuffix(name, "+") +} + +// ValidateDNSName checks shorewall's DNS name rules: fully qualified, +// minimum two periods. Returns an error if the name looks like a DNS name +// but doesn't meet the requirements. +func ValidateDNSName(name string) error { + if !strings.Contains(name, ".") { + return nil + } + count := strings.Count(name, ".") + if count < 2 { + trimmed := strings.TrimSuffix(name, ".") + if strings.Count(trimmed, ".") < 1 { + return fmt.Errorf("DNS name %q must be fully qualified with at least two periods", name) + } + } + return nil +} diff --git a/internal/config/nat.go b/internal/config/nat.go new file mode 100644 index 0000000..eabe41f --- /dev/null +++ b/internal/config/nat.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "net" +) + +// StaticNAT defines a one-to-one NAT mapping between an external and internal address. +// All traffic to the external address is forwarded to the internal address and vice versa. +// DNAT rules take precedence over static NAT rules. +type StaticNAT struct { + // External IP address. Must not be the primary address of the interface. + // DNS names are not allowed. + External string `yaml:"external"` + + // Interface that has the external address. + Interface string `yaml:"interface"` + + // Internal IP address. DNS names are not allowed. + Internal string `yaml:"internal"` + + // If true, NAT is effective from all hosts (not just those on the named interface). + AllInterfaces bool `yaml:"all_interfaces,omitempty"` + + // If true, NAT is effective from the firewall system itself. + Local bool `yaml:"local,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateStaticNAT() error { + for i, n := range c.StaticNAT { + if n.External == "" { + return fmt.Errorf("nat[%d]: external address required", i) + } + if net.ParseIP(n.External) == nil { + return fmt.Errorf("nat[%d]: external must be an IP address, not a DNS name", i) + } + + if n.Interface == "" { + return fmt.Errorf("nat[%d]: interface required", i) + } + ifaceFound := false + for _, iface := range c.Interfaces { + if iface.Interface == n.Interface || iface.PhysicalName() == n.Interface { + ifaceFound = true + break + } + } + if !ifaceFound { + return fmt.Errorf("nat[%d]: interface %q not defined in interfaces", i, n.Interface) + } + + if n.Internal == "" { + return fmt.Errorf("nat[%d]: internal address required", i) + } + if net.ParseIP(n.Internal) == nil { + return fmt.Errorf("nat[%d]: internal must be an IP address, not a DNS name", i) + } + } + return nil +} diff --git a/internal/config/nesting.go b/internal/config/nesting.go new file mode 100644 index 0000000..0921c6e --- /dev/null +++ b/internal/config/nesting.go @@ -0,0 +1,102 @@ +package config + +import "fmt" + +// ResolveNesting computes the effective zone order, ensuring child zones +// appear before their parents. This determines the order in which packets +// are matched against zones — more specific (child) zones are checked first. +func (c *Config) ResolveNesting() ([]string, error) { + resolved := make(map[string]bool) + var order []string + + for range c.Zones { + progress := false + for name, zone := range c.Zones { + if resolved[name] { + continue + } + ready := true + for _, parent := range zone.Parents { + if c.Zones[parent].Type == ZoneFirewall { + continue + } + for childName, childZone := range c.Zones { + if childName == name || resolved[childName] { + continue + } + for _, cp := range childZone.Parents { + if cp == parent && !resolved[childName] { + ready = false + } + } + } + } + _ = ready + if !resolved[name] && allParentsDepsResolved(name, c.Zones, resolved) { + resolved[name] = true + order = append(order, name) + progress = true + } + } + if !progress { + break + } + } + + if len(order) != len(c.Zones) { + return nil, fmt.Errorf("circular zone nesting detected") + } + + return order, nil +} + +func allParentsDepsResolved(name string, zones map[string]Zone, resolved map[string]bool) bool { + zone := zones[name] + if len(zone.Parents) == 0 { + return true + } + for _, parent := range zone.Parents { + for childName, childZone := range zones { + if childName == name || childName == parent { + continue + } + for _, cp := range childZone.Parents { + if cp == parent && !resolved[childName] { + return false + } + } + } + } + return true +} + +// ChildZones returns all zones that list the given zone as a parent. +func (c *Config) ChildZones(parent string) []string { + var children []string + for name, zone := range c.Zones { + for _, p := range zone.Parents { + if p == parent { + children = append(children, name) + break + } + } + } + return children +} + +// IsSubZone returns true if child is a sub-zone of parent (directly or transitively). +func (c *Config) IsSubZone(child, parent string) bool { + zone, ok := c.Zones[child] + if !ok { + return false + } + for _, p := range zone.Parents { + if p == parent { + return true + } + if c.IsSubZone(p, parent) { + return true + } + } + return false +} diff --git a/internal/config/netmap.go b/internal/config/netmap.go new file mode 100644 index 0000000..ae46f43 --- /dev/null +++ b/internal/config/netmap.go @@ -0,0 +1,83 @@ +package config + +import ( + "fmt" + "net" +) + +type NetmapType string + +const ( + NetmapDNAT NetmapType = "dnat" + NetmapSNAT NetmapType = "snat" +) + +// Netmap maps addresses in one network to corresponding addresses in another. +// For DNAT: traffic entering the interface addressed to Net1 has its dest rewritten to Net2. +// For SNAT: traffic leaving the interface with source in Net1 has its source rewritten to Net2. +type Netmap struct { + Type NetmapType `yaml:"type"` + + // Network in CIDR format to match. + Net1 string `yaml:"net1"` + + // Interface name (must be defined in interfaces). + Interface string `yaml:"interface"` + + // Network in CIDR format to rewrite to. + Net2 string `yaml:"net2"` + + // Optional qualifying network: source for DNAT rules, destination for SNAT rules. + Net3 string `yaml:"net3,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateNetmap() error { + for i, nm := range c.Netmap { + switch nm.Type { + case NetmapDNAT, NetmapSNAT: + default: + return fmt.Errorf("netmap[%d]: type must be dnat or snat, got %q", i, nm.Type) + } + + if nm.Net1 == "" { + return fmt.Errorf("netmap[%d]: net1 required", i) + } + if _, _, err := net.ParseCIDR(nm.Net1); err != nil { + return fmt.Errorf("netmap[%d]: net1 must be CIDR format: %w", i, err) + } + + if nm.Interface == "" { + return fmt.Errorf("netmap[%d]: interface required", i) + } + ifaceFound := false + for _, iface := range c.Interfaces { + if iface.Interface == nm.Interface || iface.PhysicalName() == nm.Interface { + ifaceFound = true + break + } + } + if !ifaceFound { + return fmt.Errorf("netmap[%d]: interface %q not defined in interfaces", i, nm.Interface) + } + + if nm.Net2 == "" { + return fmt.Errorf("netmap[%d]: net2 required", i) + } + if _, _, err := net.ParseCIDR(nm.Net2); err != nil { + return fmt.Errorf("netmap[%d]: net2 must be CIDR format: %w", i, err) + } + + if nm.Net3 != "" { + if _, _, err := net.ParseCIDR(nm.Net3); err != nil { + return fmt.Errorf("netmap[%d]: net3 must be CIDR format: %w", i, err) + } + } + } + return nil +} diff --git a/internal/config/params.go b/internal/config/params.go new file mode 100644 index 0000000..d04c419 --- /dev/null +++ b/internal/config/params.go @@ -0,0 +1,32 @@ +package config + +import ( + "fmt" + "strings" +) + +// SubstituteVars replaces ${var} and $var references in a string with values +// from the vars map. This is the YAML equivalent of shorewall's params file. +func SubstituteVars(s string, vars map[string]string) string { + if len(vars) == 0 || !strings.Contains(s, "$") { + return s + } + result := s + for k, v := range vars { + result = strings.ReplaceAll(result, "${"+k+"}", v) + result = strings.ReplaceAll(result, "$"+k, v) + } + return result +} + +func (c *Config) validateVars() error { + for k := range c.Vars { + if k == "" { + return fmt.Errorf("vars: empty variable name") + } + if strings.ContainsAny(k, " \t${}") { + return fmt.Errorf("vars: invalid variable name %q", k) + } + } + return nil +} diff --git a/internal/config/policy.go b/internal/config/policy.go index 2aa05ec..a4c9903 100644 --- a/internal/config/policy.go +++ b/internal/config/policy.go @@ -1,6 +1,9 @@ package config -import "fmt" +import ( + "fmt" + "strings" +) type PolicyAction string @@ -10,15 +13,35 @@ const ( PolicyReject PolicyAction = "reject" PolicyContinue PolicyAction = "continue" PolicyNone PolicyAction = "none" + PolicyQueue PolicyAction = "queue" + PolicyNFQueue PolicyAction = "nfqueue" ) +// Policy defines the default action for traffic between zones. +// Policies are evaluated in order — first match wins. +// Intra-zone traffic (zone to itself) is implicitly ACCEPTed unless +// overridden with an explicit policy or by using "all+" as source/dest. type Policy struct { - Source string `yaml:"source"` - Dest string `yaml:"dest"` - Action PolicyAction `yaml:"action"` - Log string `yaml:"log,omitempty"` - RateLimit string `yaml:"rate_limit,omitempty"` - ConnLimit int `yaml:"conn_limit,omitempty"` + // Source zone(s). Supports: zone name, "all", "all+" (overrides intra-zone ACCEPT), + // comma-separated zones ("loc,dmz"), or exclusions ("all!net"). + Source string `yaml:"source"` + + // Dest zone(s). Same syntax as Source. + Dest string `yaml:"dest"` + + Action PolicyAction `yaml:"action"` + Log string `yaml:"log,omitempty"` + + // Rate limit for TCP connections. + // Format: [s:|d:][name:]rate/{sec|min|hour|day}[:burst] + RateLimit string `yaml:"rate_limit,omitempty"` + + // Simultaneous connection limit. Format: limit[:mask] + // mask is a VLSM prefix length to apply per-subnet limiting. + ConnLimit string `yaml:"conn_limit,omitempty"` + + // NFQueue number (only used when action is nfqueue). + NFQueue int `yaml:"nfqueue,omitempty"` } func (c *Config) validatePolicy() error { @@ -26,6 +49,8 @@ func (c *Config) validatePolicy() error { return fmt.Errorf("no policies defined") } + fwZone := c.FirewallZone() + for i, p := range c.Policy { if p.Source == "" { return fmt.Errorf("policy[%d]: source required", i) @@ -33,21 +58,86 @@ func (c *Config) validatePolicy() error { if p.Dest == "" { return fmt.Errorf("policy[%d]: dest required", i) } - if p.Source != "all" { - if _, ok := c.Zones[p.Source]; !ok { - return fmt.Errorf("policy[%d]: source zone %q not defined", i, p.Source) - } - } - if p.Dest != "all" { - if _, ok := c.Zones[p.Dest]; !ok { - return fmt.Errorf("policy[%d]: dest zone %q not defined", i, p.Dest) - } - } + switch p.Action { - case PolicyAccept, PolicyDrop, PolicyReject, PolicyContinue, PolicyNone: + case PolicyAccept, PolicyDrop, PolicyReject, PolicyContinue, PolicyNone, PolicyQueue, PolicyNFQueue: default: return fmt.Errorf("policy[%d]: unknown action %q", i, p.Action) } + + if err := validatePolicyZoneRef(p.Source, c, fwZone, p.Action, "source", i); err != nil { + return err + } + if err := validatePolicyZoneRef(p.Dest, c, fwZone, p.Action, "dest", i); err != nil { + return err + } } return nil } + +// validatePolicyZoneRef validates a source or dest field, which can be: +// "all", "all+", "all!zone1,zone2", "zone1,zone2", "zone1,zone2+", or a single zone name. +func validatePolicyZoneRef(ref string, c *Config, fwZone string, action PolicyAction, field string, idx int) error { + if ref == "" { + return nil + } + + base, exclusions := parsePolicyRef(ref) + + if action == PolicyNone { + if base == "all" || base == "all+" { + return fmt.Errorf("policy[%d]: NONE may not be used with %s=%q", idx, field, ref) + } + for _, z := range splitZones(base) { + if z == fwZone { + return fmt.Errorf("policy[%d]: NONE may not be used with the firewall zone", idx) + } + } + } + + if base != "all" && base != "all+" { + for _, z := range splitZones(base) { + name := strings.TrimSuffix(z, "+") + if name != fwZone { + if _, ok := c.Zones[name]; !ok { + return fmt.Errorf("policy[%d]: %s zone %q not defined", idx, field, name) + } + } + } + } + + for _, ez := range exclusions { + if _, ok := c.Zones[ez]; !ok { + return fmt.Errorf("policy[%d]: excluded %s zone %q not defined", idx, field, ez) + } + } + + return nil +} + +// parsePolicyRef splits "all!net,dmz" into base="all" and exclusions=["net","dmz"]. +func parsePolicyRef(ref string) (base string, exclusions []string) { + if idx := strings.IndexByte(ref, '!'); idx >= 0 { + base = ref[:idx] + for _, z := range strings.Split(ref[idx+1:], ",") { + z = strings.TrimSpace(z) + if z != "" { + exclusions = append(exclusions, z) + } + } + return base, exclusions + } + return ref, nil +} + +func splitZones(ref string) []string { + ref = strings.TrimSuffix(ref, "+") + var zones []string + for _, z := range strings.Split(ref, ",") { + z = strings.TrimSpace(z) + if z != "" { + zones = append(zones, z) + } + } + return zones +} diff --git a/internal/config/providers.go b/internal/config/providers.go new file mode 100644 index 0000000..9da8f6f --- /dev/null +++ b/internal/config/providers.go @@ -0,0 +1,117 @@ +package config + +import "fmt" + +// Provider defines an additional routing table for multi-ISP or policy routing. +type Provider struct { + // Provider name. Must be a valid name; "local", "main", "default", "unspec" are reserved. + Name string `yaml:"name"` + + // Routing table number (1-252). Must be unique per provider. + Number int `yaml:"number"` + + // FWMARK value for directing packets to this provider via mangle rules. + Mark int `yaml:"mark,omitempty"` + + // Existing routing table to duplicate (e.g. "main" or another provider name). + Duplicate string `yaml:"duplicate,omitempty"` + + // Network interface to the provider. Must be defined in interfaces. + // Format: interface or interface:address (when multiple providers share an interface). + Interface string `yaml:"interface"` + + // Gateway address. Supports: IP address, "detect", "none", or omit for PPP. + Gateway string `yaml:"gateway,omitempty"` + + Options ProviderOptions `yaml:"options,omitempty"` + + // Interfaces to copy routes from when duplicating. Use "none" to only copy + // routes through the provider's own interface. + Copy []string `yaml:"copy,omitempty"` +} + +type ProviderOptions struct { + // Track inbound connections so responses route back out this interface. + Track bool `yaml:"track,omitempty"` + + // Load-balance outbound traffic across providers with balance set. + // Set to 1 for equal weight, or higher for more weight. + Balance int `yaml:"balance,omitempty"` + + // Alternative load balancing via probability (0 < p <= 1). + Load float64 `yaml:"load,omitempty"` + + // Do not create per-address routing rules for this interface. + Loose bool `yaml:"loose,omitempty"` + + // Add a default route through this provider to the main routing table. + // Set to 1 for equal weight, or higher for more weight. + Fallback int `yaml:"fallback,omitempty"` + + // Mark this as the primary provider (equivalent to balance=1). + Primary bool `yaml:"primary,omitempty"` + + // Source address for traffic routed through this provider. + Src string `yaml:"src,omitempty"` + + // MTU override when forwarding through this provider. + MTU int `yaml:"mtu,omitempty"` + + // TPROXY provider for transparent proxying. When set, mark/duplicate/gateway + // should be empty and interface should be "lo". + TProxy bool `yaml:"tproxy,omitempty"` + + // Allow the firewall to start even if this provider's interface is not up. + Optional bool `yaml:"optional,omitempty"` + + // Provider survives disable — routing table keeps its default route. + Persistent bool `yaml:"persistent,omitempty"` +} + +var reservedProviderNames = map[string]bool{ + "local": true, "main": true, "default": true, "unspec": true, +} + +func (c *Config) validateProviders() error { + seenNumbers := make(map[int]string) + seenNames := make(map[string]bool) + + for i, p := range c.Providers { + if p.Name == "" { + return fmt.Errorf("provider[%d]: name required", i) + } + if err := ValidateName(p.Name, "provider"); err != nil { + return fmt.Errorf("provider[%d]: %w", i, err) + } + if reservedProviderNames[p.Name] { + return fmt.Errorf("provider[%d]: %q is a reserved name", i, p.Name) + } + if seenNames[p.Name] { + return fmt.Errorf("provider[%d]: duplicate name %q", i, p.Name) + } + seenNames[p.Name] = true + + if p.Number < 1 || p.Number > 252 { + return fmt.Errorf("provider[%d] %q: number must be between 1 and 252", i, p.Name) + } + if existing, ok := seenNumbers[p.Number]; ok { + return fmt.Errorf("provider[%d] %q: number %d already used by %q", i, p.Name, p.Number, existing) + } + seenNumbers[p.Number] = p.Name + + if p.Interface == "" { + return fmt.Errorf("provider[%d] %q: interface required", i, p.Name) + } + + if p.Options.TProxy { + if p.Mark != 0 || p.Duplicate != "" || p.Gateway != "" { + return fmt.Errorf("provider[%d] %q: tproxy provider must have empty mark, duplicate, and gateway", i, p.Name) + } + } + + if p.Options.Load != 0 && (p.Options.Load <= 0 || p.Options.Load > 1) { + return fmt.Errorf("provider[%d] %q: load probability must be between 0 (exclusive) and 1 (inclusive)", i, p.Name) + } + } + return nil +} diff --git a/internal/config/proxyarp.go b/internal/config/proxyarp.go new file mode 100644 index 0000000..c3b72c9 --- /dev/null +++ b/internal/config/proxyarp.go @@ -0,0 +1,38 @@ +package config + +import "fmt" + +type ProxyARP struct { + // Address is the IP address to proxy ARP for. + Address string `yaml:"address"` + + // Interface is the local interface where the proxied host resides. + Interface string `yaml:"interface,omitempty"` + + // External is the external-facing interface. + External string `yaml:"external"` + + // HaveRoute indicates that a route to the address already exists, + // so no interface is required. + HaveRoute bool `yaml:"haveroute,omitempty"` + + // Persistent survives firewall restarts. + Persistent bool `yaml:"persistent,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateProxyARP() error { + for i, p := range c.ProxyARP { + if p.Address == "" { + return fmt.Errorf("proxyarp[%d]: address required", i) + } + if p.External == "" { + return fmt.Errorf("proxyarp[%d]: external required", i) + } + if p.Interface == "" && !p.HaveRoute { + return fmt.Errorf("proxyarp[%d]: interface required unless haveroute is set", i) + } + } + return nil +} diff --git a/internal/config/proxyndp.go b/internal/config/proxyndp.go new file mode 100644 index 0000000..ff3c360 --- /dev/null +++ b/internal/config/proxyndp.go @@ -0,0 +1,37 @@ +package config + +import "fmt" + +type ProxyNDP struct { + // Address is the IPv6 address to proxy NDP for. + Address string `yaml:"address"` + + // Interface is the local interface where the proxied host resides. + Interface string `yaml:"interface,omitempty"` + + // External is the external-facing interface. + External string `yaml:"external"` + + // HaveRoute indicates that a route to the address already exists. + HaveRoute bool `yaml:"haveroute,omitempty"` + + // Persistent survives firewall restarts. + Persistent bool `yaml:"persistent,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateProxyNDP() error { + for i, p := range c.ProxyNDP { + if p.Address == "" { + return fmt.Errorf("proxyndp[%d]: address required", i) + } + if p.External == "" { + return fmt.Errorf("proxyndp[%d]: external required", i) + } + if p.Interface == "" && !p.HaveRoute { + return fmt.Errorf("proxyndp[%d]: interface required unless haveroute is set", i) + } + } + return nil +} diff --git a/internal/config/routes.go b/internal/config/routes.go new file mode 100644 index 0000000..4869db5 --- /dev/null +++ b/internal/config/routes.go @@ -0,0 +1,43 @@ +package config + +import "fmt" + +type StaticRoute struct { + // Provider is the routing provider/table this route belongs to. + Provider string `yaml:"provider"` + + // Dest is the destination CIDR or host address. + Dest string `yaml:"dest"` + + // Gateway is the next-hop IP address, or one of "blackhole", "prohibit", "unreachable". + Gateway string `yaml:"gateway"` + + // Device is the outbound interface. Not allowed with blackhole/prohibit/unreachable gateways. + Device string `yaml:"device,omitempty"` + + // Persistent survives firewall restarts. + Persistent bool `yaml:"persistent,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var specialGateways = map[string]bool{ + "blackhole": true, + "prohibit": true, + "unreachable": true, +} + +func (c *Config) validateRoutes() error { + for i, r := range c.Routes { + if r.Provider == "" { + return fmt.Errorf("routes[%d]: provider required", i) + } + if r.Dest == "" { + return fmt.Errorf("routes[%d]: dest required", i) + } + if r.Device != "" && specialGateways[r.Gateway] { + return fmt.Errorf("routes[%d]: device not allowed with %s gateway", i, r.Gateway) + } + } + return nil +} diff --git a/internal/config/rtrules.go b/internal/config/rtrules.go new file mode 100644 index 0000000..080e0fc --- /dev/null +++ b/internal/config/rtrules.go @@ -0,0 +1,62 @@ +package config + +import "fmt" + +// RoutingRule directs traffic matching source/dest criteria to a specific +// provider's routing table. Requires providers to be configured. +type RoutingRule struct { + // Source address, interface, or interface:address. Use "&interface" for interface's + // primary IP. "lo" matches firewall-originated traffic. + Source string `yaml:"source,omitempty"` + + // Destination address or network in CIDR format. + Dest string `yaml:"dest,omitempty"` + + // Provider name, provider number, or "main" (254) for the main routing table. + Provider string `yaml:"provider"` + + // Numeric priority determining rule evaluation order. + // 1000-1999: before mark rules, 11000-11999: after mark rules, + // 26000-26999: after ISP interface rules. + Priority int `yaml:"priority"` + + // Persist rule even if the provider's interface is disabled. + Persistent bool `yaml:"persistent,omitempty"` + + // Packet mark match. Format: mark[/mask]. + Mark string `yaml:"mark,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateRoutingRules() error { + if len(c.RoutingRules) > 0 && len(c.Providers) == 0 { + return fmt.Errorf("rtrules require providers to be configured") + } + + providerNames := make(map[string]bool) + providerNames["main"] = true + for _, p := range c.Providers { + providerNames[p.Name] = true + providerNames[fmt.Sprintf("%d", p.Number)] = true + } + providerNames["254"] = true + + for i, r := range c.RoutingRules { + if r.Source == "" && r.Dest == "" { + return fmt.Errorf("rtrules[%d]: source or dest required", i) + } + + if r.Provider == "" { + return fmt.Errorf("rtrules[%d]: provider required", i) + } + if !providerNames[r.Provider] { + return fmt.Errorf("rtrules[%d]: provider %q not defined", i, r.Provider) + } + + if r.Priority < 1000 || r.Priority > 26999 { + return fmt.Errorf("rtrules[%d]: priority must be 1000-26999", i) + } + } + return nil +} diff --git a/internal/config/rules.go b/internal/config/rules.go index c2ba098..f3196a5 100644 --- a/internal/config/rules.go +++ b/internal/config/rules.go @@ -11,25 +11,98 @@ const ( RuleDNAT RuleAction = "dnat" RuleRedirect RuleAction = "redirect" RuleLog RuleAction = "log" + RuleContinue RuleAction = "continue" + RuleNFQueue RuleAction = "nfqueue" + RuleNoNAT RuleAction = "nonat" + RuleTarpit RuleAction = "tarpit" + RuleCount RuleAction = "count" + RuleMark RuleAction = "mark" + RuleConnMark RuleAction = "connmark" ) +type RuleSection string + +const ( + SectionAll RuleSection = "all" + SectionEstablished RuleSection = "established" + SectionRelated RuleSection = "related" + SectionInvalid RuleSection = "invalid" + SectionUntracked RuleSection = "untracked" + SectionNew RuleSection = "new" +) + +// Rule defines a specific traffic rule — an exception to the default policy. +// Rules are evaluated in order; the first terminating match wins. +// LOG, COUNT, MARK, and CONNMARK are non-terminating (packet continues to next rule). type Rule struct { - Action RuleAction `yaml:"action"` - Source string `yaml:"source"` - Dest string `yaml:"dest"` - Proto string `yaml:"proto,omitempty"` - DPort PortSpec `yaml:"dport,omitempty"` - SPort PortSpec `yaml:"sport,omitempty"` - PortGroup string `yaml:"portgroup,omitempty"` - Log string `yaml:"log,omitempty"` - DNATDest string `yaml:"dnat_dest,omitempty"` - RateLimit string `yaml:"rate_limit,omitempty"` - ConnLimit int `yaml:"conn_limit,omitempty"` - Comment string `yaml:"comment,omitempty"` + Action RuleAction `yaml:"action"` + Section RuleSection `yaml:"section,omitempty"` + + // Source zone spec. Supports: + // zone, zone:address, zone:interface, zone:interface:address + // all, all+, any, none, all!zone1,zone2 + // Multiple zones: loc,dmz + Source string `yaml:"source"` + + // Dest zone spec. Same syntax as Source. + // For DNAT: zone:server-ip:port[:random] + // For REDIRECT: port (zone is implicitly the firewall) + Dest string `yaml:"dest"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + // PortGroup references a named portgroup (mutually exclusive with proto+dport). + PortGroup string `yaml:"portgroup,omitempty"` + + Log string `yaml:"log,omitempty"` + + // OrigDest is the original destination address before DNAT/REDIRECT rewriting. + // For non-NAT rules, constrains which original dest addresses match. + OrigDest string `yaml:"origdest,omitempty"` + + // Rate limit. Format: [s:|d:][name:]rate/{sec|min|hour|day}[:burst] + RateLimit string `yaml:"rate_limit,omitempty"` + + // User/group match (only valid when source is the firewall zone). + // Format: [!]user[:group] + User string `yaml:"user,omitempty"` + + // Packet or connection mark test. Format: [!]value[/mask][:C] + Mark string `yaml:"mark,omitempty"` + + // Mark value to set (for mark/connmark actions). Format: value[/mask] + SetMark string `yaml:"set_mark,omitempty"` + + // Simultaneous connection limit. Format: [d:]limit[:mask] + ConnLimit string `yaml:"conn_limit,omitempty"` + + // Time-based restrictions. + Time *TimeSpec `yaml:"time,omitempty"` + + // Conntrack helper. Values: ftp, sip, tftp, irc, pptp, amanda, snmp, etc. + Helper string `yaml:"helper,omitempty"` + + // NFQUEUE number (only for nfqueue action). + NFQueue int `yaml:"nfqueue,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +type TimeSpec struct { + Start string `yaml:"start,omitempty"` + Stop string `yaml:"stop,omitempty"` + Weekdays []string `yaml:"weekdays,omitempty"` + Monthdays []int `yaml:"monthdays,omitempty"` + DateStart string `yaml:"date_start,omitempty"` + DateStop string `yaml:"date_stop,omitempty"` + UTC bool `yaml:"utc,omitempty"` } // PortSpec supports single ports, ranges, and lists. // Examples: [80], [443], [80, 443], ["1024-65535"], [53, "80-90"] +// For ICMP, values are interpreted as ICMP types (e.g. "echo-request", "8", "3/4"). type PortSpec []string func (ps *PortSpec) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -65,13 +138,30 @@ func (ps *PortSpec) UnmarshalYAML(unmarshal func(interface{}) error) error { return fmt.Errorf("invalid port spec") } +var validRuleActions = map[RuleAction]bool{ + RuleAccept: true, RuleDrop: true, RuleReject: true, + RuleDNAT: true, RuleRedirect: true, RuleLog: true, + RuleContinue: true, RuleNFQueue: true, RuleNoNAT: true, + RuleTarpit: true, RuleCount: true, RuleMark: true, + RuleConnMark: true, +} + +var validSections = map[RuleSection]bool{ + SectionAll: true, SectionEstablished: true, SectionRelated: true, + SectionInvalid: true, SectionUntracked: true, SectionNew: true, + "": true, +} + func (c *Config) validateRules() error { + fwZone := c.FirewallZone() + for i, r := range c.Rules { - switch r.Action { - case RuleAccept, RuleDrop, RuleReject, RuleDNAT, RuleRedirect, RuleLog: - default: + if !validRuleActions[r.Action] { return fmt.Errorf("rule[%d]: unknown action %q", i, r.Action) } + if !validSections[r.Section] { + return fmt.Errorf("rule[%d]: unknown section %q", i, r.Section) + } if r.Source == "" { return fmt.Errorf("rule[%d]: source required", i) @@ -80,17 +170,25 @@ func (c *Config) validateRules() error { return fmt.Errorf("rule[%d]: dest required", i) } - srcZone := zoneFromSpec(r.Source) - if srcZone != "all" { - if _, ok := c.Zones[srcZone]; !ok { - return fmt.Errorf("rule[%d]: source zone %q not defined", i, srcZone) + if r.Source != "all" && r.Source != "any" && r.Source != "none" && + !hasPrefix(r.Source, "all+") && !hasPrefix(r.Source, "all!") && !hasPrefix(r.Source, "any!") { + for _, srcPart := range splitZones(r.Source) { + srcZone := zoneFromSpec(srcPart) + if _, ok := c.Zones[srcZone]; !ok { + return fmt.Errorf("rule[%d]: source zone %q not defined", i, srcZone) + } } } - dstZone := zoneFromSpec(r.Dest) - if dstZone != "all" { - if _, ok := c.Zones[dstZone]; !ok { - return fmt.Errorf("rule[%d]: dest zone %q not defined", i, dstZone) + if r.Action != RuleDNAT && r.Action != RuleRedirect && r.Action != RuleNoNAT { + if r.Dest != "all" && r.Dest != "any" && r.Dest != "none" && + !hasPrefix(r.Dest, "all+") && !hasPrefix(r.Dest, "all!") && !hasPrefix(r.Dest, "any!") { + for _, dstPart := range splitZones(r.Dest) { + dstZone := zoneFromSpec(dstPart) + if _, ok := c.Zones[dstZone]; !ok { + return fmt.Errorf("rule[%d]: dest zone %q not defined", i, dstZone) + } + } } } @@ -103,8 +201,23 @@ func (c *Config) validateRules() error { } } - if r.Action == RuleDNAT && r.DNATDest == "" { - return fmt.Errorf("rule[%d]: dnat_dest required for DNAT action", i) + if r.Action == RuleDNAT && r.Dest == "" { + return fmt.Errorf("rule[%d]: dest with target address required for DNAT", i) + } + + if r.User != "" && fwZone != "" { + srcZone := zoneFromSpec(r.Source) + if srcZone != fwZone { + return fmt.Errorf("rule[%d]: user match only valid when source is the firewall zone", i) + } + } + + if (r.Action == RuleMark || r.Action == RuleConnMark) && r.SetMark == "" { + return fmt.Errorf("rule[%d]: set_mark required for %s action", i, r.Action) + } + + if r.Action == RuleTarpit && r.Proto != "tcp" && r.Proto != "" { + return fmt.Errorf("rule[%d]: tarpit only works with proto tcp", i) } } return nil @@ -119,3 +232,7 @@ func zoneFromSpec(spec string) string { } return spec } + +func hasPrefix(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} diff --git a/internal/config/secmarks.go b/internal/config/secmarks.go new file mode 100644 index 0000000..cdd7bc5 --- /dev/null +++ b/internal/config/secmarks.go @@ -0,0 +1,27 @@ +package config + +import "fmt" + +// SecmarkRule defines an SELinux security marking rule. +type SecmarkRule struct { + Secmark string `yaml:"secmark"` // SELinux context, or "save"/"restore" + Chain string `yaml:"chain"` // P/I/F/O/T with optional state + Source string `yaml:"source,omitempty"` + Dest string `yaml:"dest,omitempty"` + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateSecmarks() error { + for i, s := range c.Secmarks { + if s.Secmark == "" { + return fmt.Errorf("secmarks[%d]: secmark required", i) + } + if s.Chain == "" { + return fmt.Errorf("secmarks[%d]: chain required", i) + } + } + return nil +} diff --git a/internal/config/snat.go b/internal/config/snat.go index 64e68bf..7e9b926 100644 --- a/internal/config/snat.go +++ b/internal/config/snat.go @@ -7,22 +7,68 @@ type SNATAction string const ( SNATMasquerade SNATAction = "masquerade" SNATAddress SNATAction = "snat" + SNATContinue SNATAction = "continue" + SNATLog SNATAction = "log" ) +// SNATRule defines a source NAT or masquerade rule. +// Rules are evaluated in order — first match wins. type SNATRule struct { - Action SNATAction `yaml:"action"` - Address string `yaml:"address,omitempty"` - Source string `yaml:"source,omitempty"` - DestInterface string `yaml:"dest_interface"` - Proto string `yaml:"proto,omitempty"` - DPort PortSpec `yaml:"dport,omitempty"` - Comment string `yaml:"comment,omitempty"` + Action SNATAction `yaml:"action"` + + // For SNAT: the source address (or address range first-last) to rewrite to. + // Supports: single IP, IP range (1.2.3.4-1.2.3.7), or "detect" (use interface addresses). + Address string `yaml:"address,omitempty"` + + // Port remapping for SNAT/MASQUERADE. Format: lowport-highport or single port. + // Requires proto to be tcp, udp, dccp, or sctp. + PortRange string `yaml:"port_range,omitempty"` + + // Randomize port mapping. + Random bool `yaml:"random,omitempty"` + + // Give a client the same source/destination IP pair (only with address ranges). + Persistent bool `yaml:"persistent,omitempty"` + + // Source addresses/networks to match for masquerading. + // Supports: CIDR, host address, comma-separated list, ipset (+name). + Source string `yaml:"source,omitempty"` + + // Outgoing interface(s) and optional destination address qualification. + // Format: interface, interface:dest-address, or comma-separated interfaces. + // Use "$FW" for SNAT in the INPUT chain. + Dest string `yaml:"dest"` + + // Protocol restriction. Comma-separated list allowed. + Proto string `yaml:"proto,omitempty"` + + // Destination port(s). + DPort PortSpec `yaml:"dport,omitempty"` + + // Source port(s). + SPort PortSpec `yaml:"sport,omitempty"` + + // Packet/connection mark test. Format: [!]value[/mask][:C] + Mark string `yaml:"mark,omitempty"` + + // Original destination address filter — match only connections that were + // previously DNAT'd to these addresses. + OrigDest string `yaml:"origdest,omitempty"` + + // Random matching probability (0 < p <= 1) for load-balancing across + // multiple SNAT addresses. + Probability float64 `yaml:"probability,omitempty"` + + // Log level (for log action, or appended to other actions). + Log string `yaml:"log,omitempty"` + + Comment string `yaml:"comment,omitempty"` } func (c *Config) validateSNAT() error { for i, s := range c.SNAT { switch s.Action { - case SNATMasquerade, SNATAddress: + case SNATMasquerade, SNATAddress, SNATContinue, SNATLog: default: return fmt.Errorf("snat[%d]: unknown action %q", i, s.Action) } @@ -31,8 +77,20 @@ func (c *Config) validateSNAT() error { return fmt.Errorf("snat[%d]: address required for snat action", i) } - if s.DestInterface == "" { - return fmt.Errorf("snat[%d]: dest_interface required", i) + if s.Dest == "" { + return fmt.Errorf("snat[%d]: dest required", i) + } + + if s.PortRange != "" && s.Proto == "" { + return fmt.Errorf("snat[%d]: port_range requires proto (tcp, udp, dccp, or sctp)", i) + } + + if s.Persistent && s.Address == "" { + return fmt.Errorf("snat[%d]: persistent requires an address or address range", i) + } + + if s.Probability != 0 && (s.Probability <= 0 || s.Probability > 1) { + return fmt.Errorf("snat[%d]: probability must be between 0 (exclusive) and 1 (inclusive)", i) } } return nil diff --git a/internal/config/stoppedrules.go b/internal/config/stoppedrules.go new file mode 100644 index 0000000..5708ac7 --- /dev/null +++ b/internal/config/stoppedrules.go @@ -0,0 +1,58 @@ +package config + +import "fmt" + +type StoppedAction string + +const ( + StoppedAccept StoppedAction = "accept" + StoppedNoTrack StoppedAction = "notrack" + StoppedDrop StoppedAction = "drop" +) + +// StoppedRule defines traffic that is permitted when the firewall is stopped +// or being stopped. Without these rules, all traffic is blocked in the +// stopped state. +type StoppedRule struct { + Action StoppedAction `yaml:"action"` + + // Source: $FW (firewall), interface name, or interface:address. + Source string `yaml:"source,omitempty"` + + // Dest: $FW (firewall), interface name, or interface:address. + // May not be specified with NOTRACK or DROP actions. + Dest string `yaml:"dest,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validStoppedActions = map[StoppedAction]bool{ + StoppedAccept: true, StoppedNoTrack: true, StoppedDrop: true, +} + +func (c *Config) validateStoppedRules() error { + for i, r := range c.StoppedRules { + if !validStoppedActions[r.Action] { + return fmt.Errorf("stoppedrules[%d]: unknown action %q", i, r.Action) + } + + if r.Source == "" && r.Dest == "" { + return fmt.Errorf("stoppedrules[%d]: source or dest required", i) + } + + if (r.Action == StoppedNoTrack || r.Action == StoppedDrop) && r.Dest != "" && r.Dest != "-" { + if r.Dest != "" && r.Dest != "-" { + srcIsIface := r.Source != "" && r.Source != "$FW" && r.Source != "-" + destIsIface := r.Dest != "$FW" + if srcIsIface && destIsIface { + return fmt.Errorf("stoppedrules[%d]: dest not allowed with %s action (except $FW)", i, r.Action) + } + } + } + } + return nil +} diff --git a/internal/config/tc.go b/internal/config/tc.go new file mode 100644 index 0000000..739119e --- /dev/null +++ b/internal/config/tc.go @@ -0,0 +1,128 @@ +package config + +import "fmt" + +// TCDevice defines a traffic-shaped interface with bandwidth limits. +type TCDevice struct { + Interface string `yaml:"interface"` + InBandwidth string `yaml:"in_bandwidth,omitempty"` // ingress rate limit + OutBandwidth string `yaml:"out_bandwidth"` // egress max + Options TCDeviceOptions `yaml:"options,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +type TCDeviceOptions struct { + Classify bool `yaml:"classify,omitempty"` + HTB bool `yaml:"htb,omitempty"` + HFSC bool `yaml:"hfsc,omitempty"` + Linklayer string `yaml:"linklayer,omitempty"` // ethernet, atm, adsl +} + +// TCClass defines an HTB/HFSC traffic class with rate guarantees. +type TCClass struct { + Interface string `yaml:"interface"` // format: iface:class or iface:parent:class + Mark int `yaml:"mark,omitempty"` // 1-255 fw mark + Rate string `yaml:"rate"` // minimum guaranteed bandwidth + Ceil string `yaml:"ceil,omitempty"` // max bandwidth + Priority int `yaml:"priority,omitempty"` // scheduling order + Options TCClassOptions `yaml:"options,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +type TCClassOptions struct { + Default bool `yaml:"default,omitempty"` // default class for unclassified traffic + TCPAck bool `yaml:"tcp_ack,omitempty"` + Pfifo bool `yaml:"pfifo,omitempty"` +} + +// TCFilter classifies packets into traffic classes. +type TCFilter struct { + Class string `yaml:"class"` // interface:class + Source string `yaml:"source,omitempty"` + Dest string `yaml:"dest,omitempty"` + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + TOS string `yaml:"tos,omitempty"` + Length int `yaml:"length,omitempty"` + Priority int `yaml:"priority,omitempty"` // filter eval order + Comment string `yaml:"comment,omitempty"` +} + +// TCInterface defines simple traffic shaping (3-band priority queueing). +type TCInterface struct { + Interface string `yaml:"interface"` + Type string `yaml:"type,omitempty"` // external, internal + InBandwidth string `yaml:"in_bandwidth,omitempty"` + OutBandwidth string `yaml:"out_bandwidth,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +// TCPriority assigns packets to priority bands (1=high, 2=medium, 3=low). +type TCPriority struct { + Band int `yaml:"band"` // 1, 2, or 3 + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + Address string `yaml:"address,omitempty"` + Interface string `yaml:"interface,omitempty"` + Helper string `yaml:"helper,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateTCDevices() error { + for i, dev := range c.TCDevices { + if dev.Interface == "" { + return fmt.Errorf("tcdevices[%d]: interface required", i) + } + if dev.OutBandwidth == "" { + return fmt.Errorf("tcdevices[%d]: out_bandwidth required", i) + } + } + return nil +} + +func (c *Config) validateTCClasses() error { + for i, cls := range c.TCClasses { + if cls.Interface == "" { + return fmt.Errorf("tcclasses[%d]: interface required", i) + } + if cls.Rate == "" { + return fmt.Errorf("tcclasses[%d]: rate required", i) + } + if cls.Mark != 0 && (cls.Mark < 1 || cls.Mark > 255) { + return fmt.Errorf("tcclasses[%d]: mark must be 1-255", i) + } + } + return nil +} + +func (c *Config) validateTCFilters() error { + for i, f := range c.TCFilters { + if f.Class == "" { + return fmt.Errorf("tcfilters[%d]: class required", i) + } + if f.Source == "" && f.Dest == "" { + return fmt.Errorf("tcfilters[%d]: source or dest required", i) + } + } + return nil +} + +func (c *Config) validateTCInterfaces() error { + for i, iface := range c.TCInterfaces { + if iface.Interface == "" { + return fmt.Errorf("tcinterfaces[%d]: interface required", i) + } + } + return nil +} + +func (c *Config) validateTCPriority() error { + for i, p := range c.TCPriorities { + if p.Band < 1 || p.Band > 3 { + return fmt.Errorf("tcpriority[%d]: band must be 1, 2, or 3", i) + } + } + return nil +} diff --git a/internal/config/tunnels.go b/internal/config/tunnels.go new file mode 100644 index 0000000..60fc2af --- /dev/null +++ b/internal/config/tunnels.go @@ -0,0 +1,89 @@ +package config + +import "fmt" + +type TunnelType string + +const ( + TunnelIPSec TunnelType = "ipsec" + TunnelIPSecNAT TunnelType = "ipsecnat" + TunnelIPIP TunnelType = "ipip" + TunnelGRE TunnelType = "gre" + TunnelL2TP TunnelType = "l2tp" + TunnelPPTPClient TunnelType = "pptpclient" + TunnelPPTPServer TunnelType = "pptpserver" + TunnelOpenVPN TunnelType = "openvpn" + TunnelOpenVPNClient TunnelType = "openvpnclient" + TunnelOpenVPNServer TunnelType = "openvpnserver" + TunnelTinc TunnelType = "tinc" + Tunnel6to4 TunnelType = "6to4" + TunnelGeneric TunnelType = "generic" +) + +// Tunnel defines VPN tunnel rules that allow encapsulated traffic to pass +// between the firewall and remote gateways. The actual traffic flowing +// through the tunnel is handled by normal zone/policy/rules. +type Tunnel struct { + // Tunnel type. For ipsec, append ":ah" to use Authentication Headers (default: no AH). + // For openvpn variants, append ":tcp" or ":udp" (default: udp). + // For generic, append ":protocol" or ":protocol:port". + Type string `yaml:"type"` + + // Zone of the physical interface through which tunnel traffic passes. + Zone string `yaml:"zone"` + + // Remote tunnel gateway address(es). Use 0.0.0.0/0 or ::/0 for road warriors. + Gateways []string `yaml:"gateways"` + + // Zones that the remote gateway host belongs to (for IPSEC ISAKMP traffic). + GatewayZones []string `yaml:"gateway_zones,omitempty"` + + // Port override for openvpn/generic types (default: type-specific). + Port int `yaml:"port,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validTunnelTypes = map[TunnelType]bool{ + TunnelIPSec: true, TunnelIPSecNAT: true, + TunnelIPIP: true, TunnelGRE: true, TunnelL2TP: true, + TunnelPPTPClient: true, TunnelPPTPServer: true, + TunnelOpenVPN: true, TunnelOpenVPNClient: true, TunnelOpenVPNServer: true, + TunnelTinc: true, Tunnel6to4: true, TunnelGeneric: true, +} + +func ParseTunnelType(s string) (TunnelType, string, bool) { + for i, c := range s { + if c == ':' { + return TunnelType(s[:i]), s[i+1:], true + } + } + return TunnelType(s), "", false +} + +func (c *Config) validateTunnels() error { + for i, t := range c.Tunnels { + baseType, _, _ := ParseTunnelType(t.Type) + if !validTunnelTypes[baseType] { + return fmt.Errorf("tunnels[%d]: unknown tunnel type %q", i, baseType) + } + + if t.Zone == "" { + return fmt.Errorf("tunnels[%d]: zone required", i) + } + if _, ok := c.Zones[t.Zone]; !ok { + return fmt.Errorf("tunnels[%d]: zone %q not defined", i, t.Zone) + } + + if len(t.Gateways) == 0 { + return fmt.Errorf("tunnels[%d]: at least one gateway required", i) + } + + for _, gz := range t.GatewayZones { + if _, ok := c.Zones[gz]; !ok { + return fmt.Errorf("tunnels[%d]: gateway zone %q not defined", i, gz) + } + } + } + return nil +} diff --git a/internal/config/zones.go b/internal/config/zones.go index 65131f3..b4b87b9 100644 --- a/internal/config/zones.go +++ b/internal/config/zones.go @@ -1,6 +1,9 @@ package config -import "fmt" +import ( + "fmt" + "unicode" +) type ZoneType string @@ -8,13 +11,22 @@ const ( ZoneIP ZoneType = "ip" ZoneIPSec ZoneType = "ipsec" ZoneFirewall ZoneType = "firewall" + ZoneBPort ZoneType = "bport" ZoneLoopback ZoneType = "loopback" + ZoneLocal ZoneType = "local" ) type Zone struct { - Type ZoneType `yaml:"type"` - Parent string `yaml:"parent,omitempty"` - Options []string `yaml:"options,omitempty"` + Type ZoneType `yaml:"type"` + Parents []string `yaml:"parents,omitempty"` + Options []string `yaml:"options,omitempty"` + InOptions []string `yaml:"in_options,omitempty"` + OutOptions []string `yaml:"out_options,omitempty"` +} + +var reservedZoneNames = map[string]bool{ + "all": true, "none": true, "any": true, + "SOURCE": true, "DEST": true, } func (c *Config) validateZones() error { @@ -24,17 +36,28 @@ func (c *Config) validateZones() error { firewallCount := 0 for name, z := range c.Zones { + if err := validateZoneName(name); err != nil { + return fmt.Errorf("zone %q: %w", name, err) + } + switch z.Type { - case ZoneIP, ZoneIPSec, ZoneFirewall, ZoneLoopback: + case ZoneIP, ZoneIPSec, ZoneFirewall, ZoneBPort, ZoneLoopback, ZoneLocal: + case "": + return fmt.Errorf("zone %q: type required", name) default: return fmt.Errorf("zone %q: unknown type %q", name, z.Type) } + if z.Type == ZoneFirewall { firewallCount++ + if len(z.Options) > 0 || len(z.InOptions) > 0 || len(z.OutOptions) > 0 { + return fmt.Errorf("zone %q: firewall zone does not accept options", name) + } } - if z.Parent != "" { - if _, ok := c.Zones[z.Parent]; !ok { - return fmt.Errorf("zone %q: parent zone %q not defined", name, z.Parent) + + for _, parent := range z.Parents { + if _, ok := c.Zones[parent]; !ok { + return fmt.Errorf("zone %q: parent zone %q not defined", name, parent) } } } @@ -45,3 +68,21 @@ func (c *Config) validateZones() error { return nil } + +func validateZoneName(name string) error { + if reservedZoneNames[name] { + return fmt.Errorf("reserved name") + } + if len(name) == 0 { + return fmt.Errorf("empty name") + } + if !unicode.IsLetter(rune(name[0])) { + return fmt.Errorf("must start with a letter") + } + for _, r := range name { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' { + return fmt.Errorf("invalid character %q", r) + } + } + return nil +} diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go index 75d8745..ae6c053 100644 --- a/internal/nftables/compiler.go +++ b/internal/nftables/compiler.go @@ -26,6 +26,24 @@ func (c *Compiler) Compile() (*FirewallState, error) { Rules: make(map[string][]ManagedRule), } + c.compileLoopback(state) + if err := c.compileConntrackFastPath(state); err != nil { + return nil, fmt.Errorf("conntrack fast-path: %w", err) + } + c.compileAntiSpoof(state) + c.compileDHCP(state) + if err := c.compileIntraZone(state); err != nil { + return nil, fmt.Errorf("intra-zone: %w", err) + } + if err := c.compileBlrules(state); err != nil { + return nil, fmt.Errorf("blrules: %w", err) + } + if err := c.compileConntrack(state); err != nil { + return nil, fmt.Errorf("conntrack: %w", err) + } + if err := c.compileTunnels(state); err != nil { + return nil, fmt.Errorf("tunnels: %w", err) + } if err := c.compileRules(state); err != nil { return nil, fmt.Errorf("rules: %w", err) } @@ -35,10 +53,206 @@ func (c *Compiler) Compile() (*FirewallState, error) { if err := c.compileSNAT(state); err != nil { return nil, fmt.Errorf("snat: %w", err) } + if err := c.compileDNAT(state); err != nil { + return nil, fmt.Errorf("dnat: %w", err) + } + if err := c.compileStaticNAT(state); err != nil { + return nil, fmt.Errorf("static-nat: %w", err) + } + c.compileMSSClamp(state) return state, nil } +func (c *Compiler) compileConntrackFastPath(state *FirewallState) error { + for _, chain := range []string{"input", "forward", "output"} { + state.Rules[chain] = append(state.Rules[chain], + ManagedRule{ + Chain: chain, + Exprs: append(matchCtState(ctStateEstablished|ctStateRelated), + &expr.Verdict{Kind: expr.VerdictAccept}), + Tag: "ct:fastpath:" + chain, + }, + ManagedRule{ + Chain: chain, + Exprs: append(matchCtState(ctStateInvalid), + &expr.Verdict{Kind: expr.VerdictDrop}), + Tag: "ct:invalid:" + chain, + }, + ) + } + return nil +} + +func (c *Compiler) compileLoopback(state *FirewallState) { + for _, chain := range []string{"input", "output"} { + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: append(matchIfaceName(chain == "input", "lo"), + &expr.Verdict{Kind: expr.VerdictAccept}), + Tag: "loopback:" + chain, + }) + } +} + +func (c *Compiler) compileAntiSpoof(state *FirewallState) { + for _, iface := range c.cfg.Interfaces { + if iface.Options.NoSmurfs { + state.Rules["input"] = append(state.Rules["input"], ManagedRule{ + Chain: "input", + Exprs: matchSmurfDrop(iface.PhysicalName()), + Tag: fmt.Sprintf("antismurf:%s", iface.Interface), + }) + } + + if iface.Options.TCPFlags != nil && *iface.Options.TCPFlags { + state.Rules["input"] = append(state.Rules["input"], ManagedRule{ + Chain: "input", + Exprs: matchTCPFlagsDrop(iface.PhysicalName()), + Tag: fmt.Sprintf("tcpflags:%s", iface.Interface), + }) + } + } +} + +func (c *Compiler) compileDHCP(state *FirewallState) { + for _, iface := range c.cfg.Interfaces { + if !iface.Options.DHCP { + continue + } + name := iface.PhysicalName() + // Allow DHCPv4 client traffic (bootpc:68 → bootps:67) + state.Rules["input"] = append(state.Rules["input"], ManagedRule{ + Chain: "input", + Exprs: append(append(append( + matchIfaceName(true, name), + matchProtoNum(unix.IPPROTO_UDP)...), + matchSPort(68)...), + matchDPort(67)..., + ), + Tag: fmt.Sprintf("dhcp:in:%s", iface.Interface), + }) + // Allow DHCPv4 server → client replies + state.Rules["input"] = append(state.Rules["input"], ManagedRule{ + Chain: "input", + Exprs: append(append(append(append( + matchIfaceName(true, name), + matchProtoNum(unix.IPPROTO_UDP)...), + matchSPort(67)...), + matchDPort(68)...), + &expr.Verdict{Kind: expr.VerdictAccept}, + ), + Tag: fmt.Sprintf("dhcp:reply:%s", iface.Interface), + }) + state.Rules["output"] = append(state.Rules["output"], ManagedRule{ + Chain: "output", + Exprs: append(append(append(append( + matchIfaceName(false, name), + matchProtoNum(unix.IPPROTO_UDP)...), + matchSPort(68)...), + matchDPort(67)...), + &expr.Verdict{Kind: expr.VerdictAccept}, + ), + Tag: fmt.Sprintf("dhcp:out:%s", iface.Interface), + }) + } +} + +func (c *Compiler) compileIntraZone(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + for _, iface := range c.cfg.Interfaces { + if iface.Options.RouteBack != nil && *iface.Options.RouteBack { + chain := "forward" + if iface.Zone == fwZone { + continue + } + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: append( + append(matchIfaceName(true, iface.PhysicalName()), + matchIfaceName(false, iface.PhysicalName())...), + &expr.Verdict{Kind: expr.VerdictAccept}, + ), + Tag: fmt.Sprintf("intra:%s:%s", iface.Zone, iface.Interface), + }) + } + } + return nil +} + +func (c *Compiler) compileBlrules(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + blruleToRuleAction := map[config.BlruleAction]config.RuleAction{ + config.BlruleAccept: config.RuleAccept, + config.BlruleWhitelist: config.RuleAccept, + config.BlruleDrop: config.RuleDrop, + config.BlruleReject: config.RuleReject, + config.BlruleLog: config.RuleLog, + config.BlruleContinue: config.RuleContinue, + } + + for i, rule := range c.cfg.Blrules { + tag := fmt.Sprintf("blrule:%d", i) + action, ok := blruleToRuleAction[rule.Action] + if !ok { + action = config.RuleDrop + } + if err := c.compileOneRule(state, tag, rule.Source, rule.Dest, + rule.Proto, rule.DPort, rule.SPort, + action, rule.Log, "", fwZone, ""); err != nil { + return fmt.Errorf("blrule[%d]: %w", i, err) + } + } + return nil +} + +func (c *Compiler) compileConntrack(state *FirewallState) error { + for i, ct := range c.cfg.Conntrack { + tag := fmt.Sprintf("conntrack:%d", i) + + chains := []string{"prerouting"} + switch ct.Chain { + case config.ConntrackOutput: + chains = []string{"output"} + case config.ConntrackBoth: + chains = []string{"prerouting", "output"} + } + + for _, chain := range chains { + var exprs []expr.Any + + if ct.Proto != "" { + exprs = append(exprs, matchProto(ct.Proto)...) + } + for _, p := range ct.DPort { + pe, err := parsePortOrRange(p) + if err != nil { + return fmt.Errorf("conntrack[%d]: %w", i, err) + } + exprs = append(exprs, pe...) + } + + switch ct.Action { + case config.ConntrackNoTrack: + exprs = append(exprs, &expr.Notrack{}) + case config.ConntrackHelper: + continue + case config.ConntrackDrop: + exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictDrop}) + } + + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: exprs, + Tag: tag + ":" + chain, + }) + } + } + return nil +} + func (c *Compiler) compileRules(state *FirewallState) error { fwZone := c.cfg.FirewallZone() @@ -46,41 +260,259 @@ func (c *Compiler) compileRules(state *FirewallState) error { tag := fmt.Sprintf("rule:%d", i) proto := rule.Proto - var ports config.PortSpec + var dports config.PortSpec + var sport config.PortSpec if rule.PortGroup != "" { pg, _ := c.cfg.ResolvePortGroup(rule.PortGroup) proto = pg.Proto - ports = pg.Ports + dports = pg.Ports } else { - ports = rule.DPort + dports = rule.DPort + sport = rule.SPort } - srcZone, _ := splitZoneSpec(rule.Source) - dstZone, _ := splitZoneSpec(rule.Dest) + if err := c.compileOneRule(state, tag, rule.Source, rule.Dest, + proto, dports, sport, + rule.Action, rule.Log, rule.Dest, fwZone, rule.Section); err != nil { + return fmt.Errorf("rule[%d]: %w", i, err) + } - srcIfaces := c.resolveZoneInterfaces(srcZone) - dstIfaces := c.resolveZoneInterfaces(dstZone) - - chain := c.selectChain(srcZone, dstZone, fwZone) - - for _, srcIface := range srcIfaces { - for _, dstIface := range dstIfaces { - exprs, err := c.buildRuleExprs(srcIface, dstIface, chain, proto, ports, rule.Action, rule.Source, rule.Dest) - if err != nil { - return fmt.Errorf("rule[%d]: %w", i, err) - } - state.Rules[chain] = append(state.Rules[chain], ManagedRule{ - Chain: chain, - Exprs: exprs, - Tag: tag, - }) - } + if rule.RateLimit != "" || rule.User != "" || rule.Mark != "" || + rule.SetMark != "" || rule.ConnLimit != "" || rule.Time != nil || + rule.Action == config.RuleMark || rule.Action == config.RuleConnMark || + rule.Action == config.RuleNFQueue { + c.applyRuleExtras(state, tag, rule) } } return nil } +func (c *Compiler) applyRuleExtras(state *FirewallState, tag string, rule config.Rule) { + fwZone := c.cfg.FirewallZone() + srcZone, _ := splitZoneSpec(rule.Source) + dstZone, _ := splitZoneSpec(rule.Dest) + chain := c.selectChain(srcZone, dstZone, fwZone) + + rules := state.Rules[chain] + for idx := len(rules) - 1; idx >= 0; idx-- { + if rules[idx].Tag != tag { + break + } + + var extra []expr.Any + var replaceVerdict []expr.Any + + if rule.User != "" { + extra = append(extra, matchUID(rule.User)...) + } + if rule.Mark != "" { + extra = append(extra, matchMark(rule.Mark)...) + } + if rule.RateLimit != "" { + extra = append(extra, parseRateLimit(rule.RateLimit)...) + } + if rule.ConnLimit != "" { + extra = append(extra, matchConnLimit(rule.ConnLimit)...) + } + if rule.Time != nil { + extra = append(extra, matchTime(rule.Time)...) + } + if rule.SetMark != "" { + extra = append(extra, setMarkExprs(rule.SetMark)...) + } + if rule.Action == config.RuleNFQueue { + replaceVerdict = []expr.Any{&expr.Queue{Num: uint16(rule.NFQueue)}} + } + + if len(extra) > 0 || len(replaceVerdict) > 0 { + existingExprs := rules[idx].Exprs + var verdict []expr.Any + var nonVerdict []expr.Any + for _, e := range existingExprs { + if _, ok := e.(*expr.Verdict); ok { + verdict = append(verdict, e) + } else { + nonVerdict = append(nonVerdict, e) + } + } + if len(replaceVerdict) > 0 { + verdict = replaceVerdict + } + rules[idx].Exprs = append(append(nonVerdict, extra...), verdict...) + } + } + state.Rules[chain] = rules +} + +func (c *Compiler) compileOneRule(state *FirewallState, tag, srcSpec, dstSpec, proto string, + dports, sports config.PortSpec, action config.RuleAction, logLevel string, + dnatDest string, fwZone string, section config.RuleSection) error { + + srcZone, srcAddr := splitZoneSpec(srcSpec) + dstZone, dstAddr := splitZoneSpec(dstSpec) + + if action == config.RuleDNAT || action == config.RuleRedirect { + return c.compileDNATRule(state, tag, srcSpec, dstSpec, proto, dports, sports, action, logLevel, fwZone) + } + + srcIfaces := c.resolveZoneInterfaces(srcZone) + dstIfaces := c.resolveZoneInterfaces(dstZone) + chain := c.selectChain(srcZone, dstZone, fwZone) + + for _, srcIface := range srcIfaces { + for _, dstIface := range dstIfaces { + exprs, err := c.buildMatchExprs(srcIface, dstIface, chain, proto, dports, sports, srcAddr, dstAddr) + if err != nil { + return err + } + + if section != "" && section != config.SectionAll { + exprs = append(exprs, matchSection(section)...) + } + + if logLevel != "" { + exprs = append(exprs, buildLog(logLevel, tag)...) + } + + verdict := actionVerdict(action, proto, c.cfg.Settings.AddressFamily) + if verdict != nil { + exprs = append(exprs, verdict...) + } + + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: exprs, + Tag: tag, + }) + } + } + return nil +} + +func (c *Compiler) compileDNATRule(state *FirewallState, tag, srcSpec, dstSpec, proto string, + dports, sports config.PortSpec, action config.RuleAction, logLevel, fwZone string) error { + + srcZone, srcAddr := splitZoneSpec(srcSpec) + chain := "prerouting" + + parts := strings.SplitN(dstSpec, ":", 3) + if len(parts) < 2 { + return fmt.Errorf("DNAT dest must be zone:address or zone:address:port") + } + + dnatAddr := parts[1] + var dnatPort uint16 + if len(parts) == 3 { + p, err := strconv.ParseUint(parts[2], 10, 16) + if err != nil { + return fmt.Errorf("invalid DNAT port %q: %w", parts[2], err) + } + dnatPort = uint16(p) + } + + srcIfaces := c.resolveZoneInterfaces(srcZone) + + for _, srcIface := range srcIfaces { + var exprs []expr.Any + + if srcIface != "" { + exprs = append(exprs, matchIfaceName(true, srcIface)...) + } + + if srcAddr != "" { + src, err := matchSourceCIDR(srcAddr) + if err != nil { + return err + } + exprs = append(exprs, src...) + } + + if proto != "" { + exprs = append(exprs, matchProto(proto)...) + } + + for _, portStr := range dports { + pe, err := parsePortOrRange(portStr) + if err != nil { + return err + } + exprs = append(exprs, pe...) + } + + if logLevel != "" { + exprs = append(exprs, buildLog(logLevel, tag)...) + } + + ip := net.ParseIP(dnatAddr) + if ip == nil { + return fmt.Errorf("invalid DNAT address %q", dnatAddr) + } + + if action == config.RuleRedirect { + if dnatPort > 0 { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, dnatPort) + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: portBytes}, + &expr.Redir{RegisterProtoMin: 1}, + ) + } else { + exprs = append(exprs, &expr.Redir{}) + } + } else { + if ip4 := ip.To4(); ip4 != nil { + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: ip4}, + ) + natExpr := &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: unix.NFPROTO_IPV4, + RegAddrMin: 1, + RegAddrMax: 1, + } + if dnatPort > 0 { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, dnatPort) + exprs = append(exprs, + &expr.Immediate{Register: 2, Data: portBytes}, + ) + natExpr.RegProtoMin = 2 + natExpr.RegProtoMax = 2 + } + exprs = append(exprs, natExpr) + } else { + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: ip.To16()}, + ) + natExpr := &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: unix.NFPROTO_IPV6, + RegAddrMin: 1, + RegAddrMax: 1, + } + if dnatPort > 0 { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, dnatPort) + exprs = append(exprs, + &expr.Immediate{Register: 2, Data: portBytes}, + ) + natExpr.RegProtoMin = 2 + natExpr.RegProtoMax = 2 + } + exprs = append(exprs, natExpr) + } + } + + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: exprs, + Tag: tag, + }) + } + return nil +} + func (c *Compiler) compilePolicies(state *FirewallState) error { fwZone := c.cfg.FirewallZone() @@ -92,7 +524,7 @@ func (c *Compiler) compilePolicies(state *FirewallState) error { for _, sz := range srcZones { for _, dz := range dstZones { - if sz == dz { + if sz == dz && !strings.HasSuffix(pol.Source, "+") { continue } @@ -105,13 +537,25 @@ func (c *Compiler) compilePolicies(state *FirewallState) error { var exprs []expr.Any if si != "" { - exprs = append(exprs, matchIface(true, si)...) + exprs = append(exprs, matchIfaceName(true, si)...) } if di != "" && chain == "forward" { - exprs = append(exprs, matchIface(false, di)...) + exprs = append(exprs, matchIfaceName(false, di)...) } - exprs = append(exprs, policyVerdict(pol.Action)...) + if pol.RateLimit != "" { + exprs = append(exprs, parseRateLimit(pol.RateLimit)...) + } + + if pol.ConnLimit != "" { + exprs = append(exprs, matchConnLimit(pol.ConnLimit)...) + } + + if pol.Log != "" { + exprs = append(exprs, buildLog(pol.Log, tag)...) + } + + exprs = append(exprs, policyVerdict(pol.Action, c.cfg.Settings.AddressFamily)...) state.Rules[chain] = append(state.Rules[chain], ManagedRule{ Chain: chain, @@ -133,7 +577,8 @@ func (c *Compiler) compileSNAT(state *FirewallState) error { var exprs []expr.Any - exprs = append(exprs, matchIface(false, snat.DestInterface)...) + destIface, _ := splitZoneSpec(snat.Dest) + exprs = append(exprs, matchIfaceName(false, destIface)...) if snat.Source != "" { srcExprs, err := matchSourceCIDR(snat.Source) @@ -147,9 +592,37 @@ func (c *Compiler) compileSNAT(state *FirewallState) error { exprs = append(exprs, matchProto(snat.Proto)...) } + for _, portStr := range snat.DPort { + pe, err := parsePortOrRange(portStr) + if err != nil { + return fmt.Errorf("snat[%d] dport: %w", i, err) + } + exprs = append(exprs, pe...) + } + + for _, portStr := range snat.SPort { + pe, err := parseSPortOrRange(portStr) + if err != nil { + return fmt.Errorf("snat[%d] sport: %w", i, err) + } + exprs = append(exprs, pe...) + } + + if snat.Mark != "" { + exprs = append(exprs, matchMark(snat.Mark)...) + } + + if snat.Log != "" { + exprs = append(exprs, buildLog(snat.Log, tag)...) + } + switch snat.Action { case config.SNATMasquerade: - exprs = append(exprs, &expr.Masq{}) + masq := &expr.Masq{} + if snat.Random { + masq.Random = true + } + exprs = append(exprs, masq) case config.SNATAddress: ip := net.ParseIP(snat.Address) if ip == nil { @@ -160,20 +633,20 @@ func (c *Compiler) compileSNAT(state *FirewallState) error { exprs = append(exprs, &expr.Immediate{Register: 1, Data: ip4}, &expr.NAT{ - Type: expr.NATTypeSourceNAT, - Family: unix.NFPROTO_IPV4, - RegAddrMin: 1, - RegAddrMax: 1, + Type: expr.NATTypeSourceNAT, + Family: unix.NFPROTO_IPV4, + RegAddrMin: 1, + RegAddrMax: 1, }, ) } else { exprs = append(exprs, &expr.Immediate{Register: 1, Data: ip.To16()}, &expr.NAT{ - Type: expr.NATTypeSourceNAT, - Family: unix.NFPROTO_IPV6, - RegAddrMin: 1, - RegAddrMax: 1, + Type: expr.NATTypeSourceNAT, + Family: unix.NFPROTO_IPV6, + RegAddrMin: 1, + RegAddrMax: 1, }, ) } @@ -189,6 +662,217 @@ func (c *Compiler) compileSNAT(state *FirewallState) error { return nil } +func (c *Compiler) compileDNAT(state *FirewallState) error { + return nil +} + +func (c *Compiler) compileTunnels(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + for i, tun := range c.cfg.Tunnels { + baseType, extra, _ := config.ParseTunnelType(tun.Type) + tag := fmt.Sprintf("tunnel:%d", i) + + inChain := c.selectChain(tun.Zone, fwZone, fwZone) + outChain := c.selectChain(fwZone, tun.Zone, fwZone) + + for _, gw := range tun.Gateways { + var srcMatch, dstMatch []expr.Any + if gw != "0.0.0.0/0" && gw != "::/0" { + var err error + srcMatch, err = matchSourceCIDR(gw) + if err != nil { + return fmt.Errorf("tunnel[%d]: %w", i, err) + } + dstMatch, err = matchDestCIDR(gw) + if err != nil { + return fmt.Errorf("tunnel[%d]: %w", i, err) + } + } + + addTunnelRule := func(chain string, proto byte, dport uint16, srcExprs []expr.Any) { + var exprs []expr.Any + exprs = append(exprs, srcExprs...) + exprs = append(exprs, matchProtoNum(proto)...) + if dport > 0 { + exprs = append(exprs, matchDPort(dport)...) + } + exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictAccept}) + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, Exprs: exprs, Tag: tag, + }) + } + + switch baseType { + case config.TunnelIPSec, config.TunnelIPSecNAT: + addTunnelRule(inChain, 50, 0, srcMatch) + addTunnelRule(outChain, 50, 0, dstMatch) + if extra != "ah" { + addTunnelRule(inChain, 51, 0, srcMatch) + addTunnelRule(outChain, 51, 0, dstMatch) + } + addTunnelRule(inChain, unix.IPPROTO_UDP, 500, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_UDP, 500, dstMatch) + if baseType == config.TunnelIPSecNAT { + addTunnelRule(inChain, unix.IPPROTO_UDP, 4500, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_UDP, 4500, dstMatch) + } + + case config.TunnelIPIP, config.Tunnel6to4: + addTunnelRule(inChain, 4, 0, srcMatch) + addTunnelRule(outChain, 4, 0, dstMatch) + + case config.TunnelGRE: + addTunnelRule(inChain, 47, 0, srcMatch) + addTunnelRule(outChain, 47, 0, dstMatch) + + case config.TunnelOpenVPN, config.TunnelOpenVPNClient, config.TunnelOpenVPNServer: + proto := unix.IPPROTO_UDP + if extra == "tcp" { + proto = unix.IPPROTO_TCP + } + port := uint16(1194) + if tun.Port > 0 { + port = uint16(tun.Port) + } + addTunnelRule(inChain, byte(proto), port, srcMatch) + addTunnelRule(outChain, byte(proto), port, dstMatch) + + case config.TunnelL2TP: + addTunnelRule(inChain, unix.IPPROTO_UDP, 1701, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_UDP, 1701, dstMatch) + + case config.TunnelTinc: + addTunnelRule(inChain, unix.IPPROTO_UDP, 655, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_UDP, 655, dstMatch) + addTunnelRule(inChain, unix.IPPROTO_TCP, 655, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_TCP, 655, dstMatch) + + case config.TunnelPPTPClient: + addTunnelRule(inChain, 47, 0, srcMatch) + addTunnelRule(outChain, 47, 0, dstMatch) + addTunnelRule(outChain, unix.IPPROTO_TCP, 1723, dstMatch) + + case config.TunnelPPTPServer: + addTunnelRule(inChain, 47, 0, srcMatch) + addTunnelRule(outChain, 47, 0, dstMatch) + addTunnelRule(inChain, unix.IPPROTO_TCP, 1723, srcMatch) + + case config.TunnelGeneric: + proto := unix.IPPROTO_UDP + if extra == "tcp" { + proto = unix.IPPROTO_TCP + } + port := uint16(0) + if tun.Port > 0 { + port = uint16(tun.Port) + } + addTunnelRule(inChain, byte(proto), port, srcMatch) + addTunnelRule(outChain, byte(proto), port, dstMatch) + } + } + } + return nil +} + +func (c *Compiler) compileMSSClamp(state *FirewallState) { + for _, iface := range c.cfg.Interfaces { + if iface.Options.MSS > 0 { + mssBytes := make([]byte, 2) + binary.BigEndian.PutUint16(mssBytes, uint16(iface.Options.MSS)) + var exprs []expr.Any + exprs = append(exprs, matchIfaceName(false, iface.PhysicalName())...) + exprs = append(exprs, matchProtoNum(unix.IPPROTO_TCP)...) + exprs = append(exprs, matchTCPFlags(0x02, 0x02)...) + exprs = append(exprs, + &expr.Exthdr{ + DestRegister: 1, + Type: 2, + Offset: 2, + Len: 2, + Op: 0, + }, + &expr.Cmp{Op: expr.CmpOpGt, Register: 1, Data: mssBytes}, + &expr.Immediate{Register: 1, Data: mssBytes}, + &expr.Exthdr{ + SourceRegister: 1, + Type: 2, + Offset: 2, + Len: 2, + Op: 1, + }, + ) + state.Rules["forward"] = append(state.Rules["forward"], ManagedRule{ + Chain: "forward", + Exprs: exprs, + Tag: fmt.Sprintf("mss:%s", iface.Interface), + }) + } + } +} + +func (c *Compiler) compileStaticNAT(state *FirewallState) error { + for i, sn := range c.cfg.StaticNAT { + extIP := net.ParseIP(sn.External) + intIP := net.ParseIP(sn.Internal) + if extIP == nil || intIP == nil { + return fmt.Errorf("static-nat[%d]: invalid IP", i) + } + + family := unix.NFPROTO_IPV4 + ext4 := extIP.To4() + int4 := intIP.To4() + if ext4 == nil || int4 == nil { + family = unix.NFPROTO_IPV6 + } + + dnatTag := fmt.Sprintf("staticnat:dnat:%d", i) + var dnatExprs []expr.Any + dnatExprs = append(dnatExprs, matchIfaceName(true, sn.Interface)...) + if family == unix.NFPROTO_IPV4 { + dst, _ := matchDestCIDR(sn.External) + dnatExprs = append(dnatExprs, dst...) + dnatExprs = append(dnatExprs, + &expr.Immediate{Register: 1, Data: int4}, + &expr.NAT{Type: expr.NATTypeDestNAT, Family: uint32(family), RegAddrMin: 1, RegAddrMax: 1}, + ) + } else { + dst, _ := matchDestCIDR(sn.External) + dnatExprs = append(dnatExprs, dst...) + dnatExprs = append(dnatExprs, + &expr.Immediate{Register: 1, Data: intIP.To16()}, + &expr.NAT{Type: expr.NATTypeDestNAT, Family: uint32(family), RegAddrMin: 1, RegAddrMax: 1}, + ) + } + state.Rules["prerouting"] = append(state.Rules["prerouting"], ManagedRule{ + Chain: "prerouting", Exprs: dnatExprs, Tag: dnatTag, + }) + + snatTag := fmt.Sprintf("staticnat:snat:%d", i) + var snatExprs []expr.Any + snatExprs = append(snatExprs, matchIfaceName(false, sn.Interface)...) + if family == unix.NFPROTO_IPV4 { + src, _ := matchSourceCIDR(sn.Internal) + snatExprs = append(snatExprs, src...) + snatExprs = append(snatExprs, + &expr.Immediate{Register: 1, Data: ext4}, + &expr.NAT{Type: expr.NATTypeSourceNAT, Family: uint32(family), RegAddrMin: 1, RegAddrMax: 1}, + ) + } else { + src, _ := matchSourceCIDR(sn.Internal) + snatExprs = append(snatExprs, src...) + snatExprs = append(snatExprs, + &expr.Immediate{Register: 1, Data: extIP.To16()}, + &expr.NAT{Type: expr.NATTypeSourceNAT, Family: uint32(family), RegAddrMin: 1, RegAddrMax: 1}, + ) + } + state.Rules["postrouting"] = append(state.Rules["postrouting"], ManagedRule{ + Chain: "postrouting", Exprs: snatExprs, Tag: snatTag, + }) + } + return nil +} + func (c *Compiler) selectChain(srcZone, dstZone, fwZone string) string { if dstZone == fwZone { return "input" @@ -211,29 +895,43 @@ func (c *Compiler) resolveZoneInterfaces(zone string) []string { } func (c *Compiler) expandZoneRef(ref string) []string { - if ref == "all" { + base := ref + var excluded map[string]bool + + if idx := strings.IndexByte(ref, '!'); idx >= 0 { + base = ref[:idx] + excluded = make(map[string]bool) + for _, z := range strings.Split(ref[idx+1:], ",") { + z = strings.TrimSpace(z) + if z != "" { + excluded[z] = true + } + } + } + + if base == "all" || base == "all+" { var zones []string for name := range c.cfg.Zones { + if excluded != nil && excluded[name] { + continue + } zones = append(zones, name) } return zones } - return []string{ref} + return []string{base} } -func (c *Compiler) buildRuleExprs(srcIface, dstIface, chain, proto string, ports config.PortSpec, action config.RuleAction, srcSpec, dstSpec string) ([]expr.Any, error) { +func (c *Compiler) buildMatchExprs(srcIface, dstIface, chain, proto string, dports, sports config.PortSpec, srcAddr, dstAddr string) ([]expr.Any, error) { var exprs []expr.Any if srcIface != "" { - exprs = append(exprs, matchIface(true, srcIface)...) + exprs = append(exprs, matchIfaceName(true, srcIface)...) } if dstIface != "" && chain == "forward" { - exprs = append(exprs, matchIface(false, dstIface)...) + exprs = append(exprs, matchIfaceName(false, dstIface)...) } - _, srcAddr := splitZoneSpec(srcSpec) - _, dstAddr := splitZoneSpec(dstSpec) - if srcAddr != "" { src, err := matchSourceCIDR(srcAddr) if err != nil { @@ -254,31 +952,49 @@ func (c *Compiler) buildRuleExprs(srcIface, dstIface, chain, proto string, ports exprs = append(exprs, matchProto(proto)...) } - for _, portStr := range ports { - p, err := parsePort(portStr) + isICMP := strings.EqualFold(proto, "icmp") || strings.EqualFold(proto, "icmpv6") || strings.EqualFold(proto, "ipv6-icmp") + + for _, portStr := range dports { + if isICMP { + pe := matchICMPType(portStr) + exprs = append(exprs, pe...) + } else { + pe, err := parsePortOrRange(portStr) + if err != nil { + return nil, err + } + exprs = append(exprs, pe...) + } + } + + for _, portStr := range sports { + pe, err := parseSPortOrRange(portStr) if err != nil { return nil, err } - exprs = append(exprs, matchDPort(p)...) - } - - switch action { - case config.RuleAccept: - exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictAccept}) - case config.RuleDrop: - exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictDrop}) - case config.RuleReject: - exprs = append(exprs, &expr.Reject{}) + exprs = append(exprs, pe...) } return exprs, nil } -func matchIface(input bool, name string) []expr.Any { +// matchIfaceName matches an interface name, supporting wildcard "+" suffix. +func matchIfaceName(input bool, name string) []expr.Any { key := expr.MetaKeyOIFNAME if input { key = expr.MetaKeyIIFNAME } + + if strings.HasSuffix(name, "+") { + prefix := strings.TrimSuffix(name, "+") + padded := make([]byte, len(prefix)) + copy(padded, prefix) + return []expr.Any{ + &expr.Meta{Key: key, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: padded}, + } + } + padded := make([]byte, 16) copy(padded, name+"\x00") return []expr.Any{ @@ -296,6 +1012,16 @@ func matchProto(proto string) []expr.Any { protoNum = unix.IPPROTO_UDP case "icmp": protoNum = unix.IPPROTO_ICMP + case "icmpv6", "ipv6-icmp": + protoNum = unix.IPPROTO_ICMPV6 + case "gre": + protoNum = 47 + case "esp": + protoNum = 50 + case "ah": + protoNum = 51 + case "sctp": + protoNum = unix.IPPROTO_SCTP default: n, _ := strconv.Atoi(proto) protoNum = byte(n) @@ -315,78 +1041,630 @@ func matchDPort(port uint16) []expr.Any { } } -func matchSourceCIDR(cidr string) ([]expr.Any, error) { - ip, ipNet, err := net.ParseCIDR(cidr) - if err != nil { - singleIP := net.ParseIP(cidr) - if singleIP == nil { - return nil, fmt.Errorf("invalid source address %q", cidr) - } - ip4 := singleIP.To4() - return []expr.Any{ - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ip4}, - }, nil - } - - ip4 := ip.To4() - if ip4 == nil { - return nil, fmt.Errorf("IPv6 source addresses not yet supported: %s", cidr) - } - +func matchDPortRange(low, high uint16) []expr.Any { + lowBytes := make([]byte, 2) + highBytes := make([]byte, 2) + binary.BigEndian.PutUint16(lowBytes, low) + binary.BigEndian.PutUint16(highBytes, high) return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpGte, Register: 1, Data: lowBytes}, + &expr.Cmp{Op: expr.CmpOpLte, Register: 1, Data: highBytes}, + } +} + +func matchSPort(port uint16) []expr.Any { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, port) + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes}, + } +} + +func matchSPortRange(low, high uint16) []expr.Any { + lowBytes := make([]byte, 2) + highBytes := make([]byte, 2) + binary.BigEndian.PutUint16(lowBytes, low) + binary.BigEndian.PutUint16(highBytes, high) + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2}, + &expr.Cmp{Op: expr.CmpOpGte, Register: 1, Data: lowBytes}, + &expr.Cmp{Op: expr.CmpOpLte, Register: 1, Data: highBytes}, + } +} + +func matchProtoNum(proto byte) []expr.Any { + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{proto}}, + } +} + +func matchTCPFlags(flags, mask byte) []expr.Any { + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 13, Len: 1}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 1, + Mask: []byte{mask}, + Xor: []byte{0}, + }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{flags}}, + } +} + +func matchSmurfDrop(iface string) []expr.Any { + var exprs []expr.Any + exprs = append(exprs, matchIfaceName(true, iface)...) + exprs = append(exprs, &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, &expr.Bitwise{ SourceRegister: 1, DestRegister: 1, Len: 4, - Mask: ipNet.Mask, + Mask: []byte{0xf0, 0, 0, 0}, Xor: []byte{0, 0, 0, 0}, }, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ipNet.IP.To4()}, - }, nil + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{0xe0, 0, 0, 0}}, + &expr.Verdict{Kind: expr.VerdictDrop}, + ) + return exprs +} + +func matchTCPFlagsDrop(iface string) []expr.Any { + var exprs []expr.Any + exprs = append(exprs, matchIfaceName(true, iface)...) + exprs = append(exprs, matchProtoNum(unix.IPPROTO_TCP)...) + exprs = append(exprs, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 13, Len: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{0}}, + &expr.Verdict{Kind: expr.VerdictDrop}, + ) + return exprs +} + +var icmpTypeNames = map[string]byte{ + "echo-reply": 0, + "destination-unreachable": 3, + "source-quench": 4, + "redirect": 5, + "echo-request": 8, + "router-advertisement": 9, + "router-solicitation": 10, + "time-exceeded": 11, + "parameter-problem": 12, + "timestamp-request": 13, + "timestamp-reply": 14, + "address-mask-request": 17, + "address-mask-reply": 18, +} + +func matchICMPType(spec string) []expr.Any { + if strings.Contains(spec, "/") { + parts := strings.SplitN(spec, "/", 2) + typeVal, ok := resolveICMPType(parts[0]) + if !ok { + return nil + } + code, err := strconv.ParseUint(parts[1], 10, 8) + if err != nil { + return nil + } + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{typeVal}}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 1, Len: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{byte(code)}}, + } + } + + typeVal, ok := resolveICMPType(spec) + if !ok { + return nil + } + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{typeVal}}, + } +} + +func resolveICMPType(s string) (byte, bool) { + if v, ok := icmpTypeNames[strings.ToLower(s)]; ok { + return v, true + } + n, err := strconv.ParseUint(s, 10, 8) + if err != nil { + return 0, false + } + return byte(n), true +} + +// Time matching requires NFT_META_TIME_* keys not exposed in google/nftables v0.2.0. +func matchTime(_ *config.TimeSpec) []expr.Any { + return nil +} + +func matchConnLimit(spec string) []expr.Any { + s := spec + flags := uint32(0) + if strings.HasPrefix(s, "d:") { + flags = 1 + s = s[2:] + } + + var count uint32 + if idx := strings.IndexByte(s, ':'); idx >= 0 { + c, err := strconv.ParseUint(s[:idx], 10, 32) + if err != nil { + return nil + } + count = uint32(c) + } else { + c, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return nil + } + count = uint32(c) + } + + return []expr.Any{ + &expr.Connlimit{ + Count: count, + Flags: flags, + }, + } +} + +func parsePortOrRange(s string) ([]expr.Any, error) { + if strings.Contains(s, "-") { + parts := strings.SplitN(s, "-", 2) + low, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port range low %q: %w", parts[0], err) + } + high, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port range high %q: %w", parts[1], err) + } + return matchDPortRange(uint16(low), uint16(high)), nil + } + p, err := parsePort(s) + if err != nil { + return nil, err + } + return matchDPort(p), nil +} + +func parseSPortOrRange(s string) ([]expr.Any, error) { + if strings.Contains(s, "-") { + parts := strings.SplitN(s, "-", 2) + low, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid sport range low %q: %w", parts[0], err) + } + high, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid sport range high %q: %w", parts[1], err) + } + return matchSPortRange(uint16(low), uint16(high)), nil + } + p, err := parsePort(s) + if err != nil { + return nil, err + } + return matchSPort(p), nil +} + +func matchSourceCIDR(cidr string) ([]expr.Any, error) { + return matchAddrCIDR(cidr, true) } func matchDestCIDR(cidr string) ([]expr.Any, error) { + return matchAddrCIDR(cidr, false) +} + +func matchAddrCIDR(cidr string, isSrc bool) ([]expr.Any, error) { + negated := false + if strings.HasPrefix(cidr, "!") { + negated = true + cidr = cidr[1:] + } + + cmpOp := expr.CmpOpEq + if negated { + cmpOp = expr.CmpOpNeq + } + + var offset4, offset6 uint32 + if isSrc { + offset4, offset6 = 12, 8 + } else { + offset4, offset6 = 16, 24 + } + ip, ipNet, err := net.ParseCIDR(cidr) if err != nil { singleIP := net.ParseIP(cidr) if singleIP == nil { + if isSrc { + return nil, fmt.Errorf("invalid source address %q", cidr) + } return nil, fmt.Errorf("invalid dest address %q", cidr) } - ip4 := singleIP.To4() + if ip4 := singleIP.To4(); ip4 != nil { + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset4, Len: 4}, + &expr.Cmp{Op: cmpOp, Register: 1, Data: ip4}, + }, nil + } + ip6 := singleIP.To16() return []expr.Any{ - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ip4}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset6, Len: 16}, + &expr.Cmp{Op: cmpOp, Register: 1, Data: ip6}, }, nil } - ip4 := ip.To4() - if ip4 == nil { - return nil, fmt.Errorf("IPv6 dest addresses not yet supported: %s", cidr) + if ip4 := ip.To4(); ip4 != nil { + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset4, Len: 4}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: ipNet.Mask, + Xor: []byte{0, 0, 0, 0}, + }, + &expr.Cmp{Op: cmpOp, Register: 1, Data: ipNet.IP.To4()}, + }, nil } return []expr.Any{ - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset6, Len: 16}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 16, + Mask: ipNet.Mask, + Xor: make([]byte, 16), + }, + &expr.Cmp{Op: cmpOp, Register: 1, Data: ipNet.IP.To16()}, + }, nil +} + +const ( + ctStateInvalid = 1 + ctStateEstablished = 2 + ctStateRelated = 4 + ctStateNew = 8 + ctStateUntracked = 64 +) + +func matchCtState(stateMask uint32) []expr.Any { + stateBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(stateBytes, stateMask) + return []expr.Any{ + &expr.Ct{Key: expr.CtKeySTATE, Register: 1}, &expr.Bitwise{ SourceRegister: 1, DestRegister: 1, Len: 4, - Mask: ipNet.Mask, - Xor: []byte{0, 0, 0, 0}, + Mask: stateBytes, + Xor: make([]byte, 4), }, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ipNet.IP.To4()}, - }, nil + &expr.Cmp{Op: expr.CmpOpNeq, Register: 1, Data: make([]byte, 4)}, + } } -func policyVerdict(action config.PolicyAction) []expr.Any { +func matchSection(section config.RuleSection) []expr.Any { + switch section { + case config.SectionEstablished: + return matchCtState(ctStateEstablished) + case config.SectionRelated: + return matchCtState(ctStateRelated) + case config.SectionInvalid: + return matchCtState(ctStateInvalid) + case config.SectionUntracked: + return matchCtState(ctStateUntracked) + case config.SectionNew: + return matchCtState(ctStateNew) + default: + return nil + } +} + +func parseRateLimit(spec string) []expr.Any { + s := spec + if strings.HasPrefix(s, "s:") || strings.HasPrefix(s, "d:") { + s = s[2:] + } + if idx := strings.IndexByte(s, ':'); idx > 0 { + if strings.Contains(s[:idx], "/") { + // name:rate/unit:burst → skip name + } else { + s = s[idx+1:] + } + } + + var burst uint32 + if idx := strings.LastIndexByte(s, ':'); idx > 0 { + b, err := strconv.ParseUint(s[idx+1:], 10, 32) + if err == nil { + burst = uint32(b) + s = s[:idx] + } + } + + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 { + return nil + } + + rate, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil || rate == 0 { + return nil + } + + var unit expr.LimitTime + switch strings.ToLower(parts[1]) { + case "sec", "second": + unit = expr.LimitTimeSecond + case "min", "minute": + unit = expr.LimitTimeMinute + case "hour": + unit = expr.LimitTimeHour + case "day": + unit = expr.LimitTimeDay + default: + return nil + } + + if burst == 0 { + burst = 5 + } + + return []expr.Any{ + &expr.Limit{ + Type: expr.LimitTypePkts, + Rate: rate, + Unit: unit, + Burst: burst, + }, + } +} + +func matchUID(userSpec string) []expr.Any { + negated := false + s := userSpec + if strings.HasPrefix(s, "!") { + negated = true + s = s[1:] + } + if idx := strings.IndexByte(s, ':'); idx >= 0 { + s = s[:idx] + } + + uid, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return nil + } + + uidBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(uidBytes, uint32(uid)) + + op := expr.CmpOpEq + if negated { + op = expr.CmpOpNeq + } + + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeySKUID, Register: 1}, + &expr.Cmp{Op: op, Register: 1, Data: uidBytes}, + } +} + +func matchMark(markSpec string) []expr.Any { + negated := false + s := markSpec + if strings.HasPrefix(s, "!") { + negated = true + s = s[1:] + } + + connMark := false + if strings.HasSuffix(s, ":C") { + connMark = true + s = strings.TrimSuffix(s, ":C") + } + + var value, mask uint32 + if idx := strings.IndexByte(s, '/'); idx >= 0 { + v, err := strconv.ParseUint(s[:idx], 0, 32) + if err != nil { + return nil + } + m, err := strconv.ParseUint(s[idx+1:], 0, 32) + if err != nil { + return nil + } + value = uint32(v) + mask = uint32(m) + } else { + v, err := strconv.ParseUint(s, 0, 32) + if err != nil { + return nil + } + value = uint32(v) + mask = 0xffffffff + } + + valBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(valBytes, value) + maskBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(maskBytes, mask) + + op := expr.CmpOpEq + if negated { + op = expr.CmpOpNeq + } + + var loadExpr expr.Any + if connMark { + loadExpr = &expr.Ct{Key: expr.CtKeyMARK, Register: 1} + } else { + loadExpr = &expr.Meta{Key: expr.MetaKeyMARK, Register: 1} + } + + if mask != 0xffffffff { + return []expr.Any{ + loadExpr, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: maskBytes, + Xor: make([]byte, 4), + }, + &expr.Cmp{Op: op, Register: 1, Data: valBytes}, + } + } + return []expr.Any{ + loadExpr, + &expr.Cmp{Op: op, Register: 1, Data: valBytes}, + } +} + +func setMarkExprs(markSpec string) []expr.Any { + var value, mask uint32 + if idx := strings.IndexByte(markSpec, '/'); idx >= 0 { + v, err := strconv.ParseUint(markSpec[:idx], 0, 32) + if err != nil { + return nil + } + m, err := strconv.ParseUint(markSpec[idx+1:], 0, 32) + if err != nil { + return nil + } + value = uint32(v) + mask = uint32(m) + } else { + v, err := strconv.ParseUint(markSpec, 0, 32) + if err != nil { + return nil + } + value = uint32(v) + mask = 0xffffffff + } + + valBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(valBytes, value) + + if mask != 0xffffffff { + maskBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(maskBytes, mask) + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeyMARK, Register: 1}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: maskBytes, + Xor: valBytes, + }, + &expr.Meta{Key: expr.MetaKeyMARK, SourceRegister: true, Register: 1}, + } + } + return []expr.Any{ + &expr.Immediate{Register: 1, Data: valBytes}, + &expr.Meta{Key: expr.MetaKeyMARK, SourceRegister: true, Register: 1}, + } +} + +func buildLog(level, prefix string) []expr.Any { + nfLevel := logLevelToNF(level) + logPrefix := prefix + if len(logPrefix) > 63 { + logPrefix = logPrefix[:63] + } + return []expr.Any{ + &expr.Log{ + Key: 1 << unix.NFTA_LOG_PREFIX | 1<= 4 (iface + log + verdict)", len(r.Exprs)) + } + break + } + } + if !found { + t.Error("policy:0 not found in input chain") + } +} + +func TestCompile_ConntrackNoTrack(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Conntrack: []config.ConntrackRule{ + { + Action: config.ConntrackNoTrack, + Source: "net", + Dest: "fw", + Proto: "udp", + DPort: config.PortSpec{"53"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["prerouting"] { + if r.Tag == "conntrack:0:prerouting" { + found = true + break + } + } + if !found { + t.Error("no notrack rule found in prerouting chain") + } +} + +func TestLogLevelToNF(t *testing.T) { + tests := []struct { + input string + want expr.LogLevel + }{ + {"emerg", expr.LogLevelEmerg}, + {"alert", expr.LogLevelAlert}, + {"crit", expr.LogLevelCrit}, + {"err", expr.LogLevelErr}, + {"error", expr.LogLevelErr}, + {"warn", expr.LogLevelWarning}, + {"warning", expr.LogLevelWarning}, + {"notice", expr.LogLevelNotice}, + {"info", expr.LogLevelInfo}, + {"debug", expr.LogLevelDebug}, + {"unknown", expr.LogLevelWarning}, + } + + for _, tt := range tests { + got := logLevelToNF(tt.input) + if got != tt.want { + t.Errorf("logLevelToNF(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} + +func TestDiffEngine_DetectsModifications(t *testing.T) { + current := &FirewallState{ + Rules: map[string][]ManagedRule{ + "input": { + {Chain: "input", Tag: "rule:0", Exprs: []expr.Any{ + &expr.Verdict{Kind: expr.VerdictAccept}, + }}, + }, + }, + } + + desired := &FirewallState{ + Rules: map[string][]ManagedRule{ + "input": { + {Chain: "input", Tag: "rule:0", Exprs: []expr.Any{ + &expr.Verdict{Kind: expr.VerdictDrop}, + }}, + }, + }, + } + + cs := computeDiff(current, desired) + if len(cs.Remove) != 1 { + t.Errorf("expected 1 removal, got %d", len(cs.Remove)) + } + if len(cs.Add) != 1 { + t.Errorf("expected 1 addition, got %d", len(cs.Add)) + } +} + +func TestDiffEngine_NoChangeWhenIdentical(t *testing.T) { + state := &FirewallState{ + Rules: map[string][]ManagedRule{ + "input": { + {Chain: "input", Tag: "rule:0", Exprs: []expr.Any{ + &expr.Verdict{Kind: expr.VerdictAccept}, + }}, + }, + }, + } + + cs := computeDiff(state, state) + if !cs.Empty() { + t.Errorf("expected empty changeset, got %d adds and %d removes", len(cs.Add), len(cs.Remove)) + } +} + +func TestCompile_SPortMatching(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + SPort: config.PortSpec{"1024-65535"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + found = true + break + } + } + if !found { + t.Error("rule with sport not found in input chain") + } +} + +func TestCompile_PortRange(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"1024-65535"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + found = true + break + } + } + if !found { + t.Error("rule with port range not found") + } +} + +func TestCompile_LogAction(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleLog, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + Log: "info", + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + found = true + break + } + } + if !found { + t.Error("log rule not found") + } +} + +func TestMatchSection(t *testing.T) { + tests := []struct { + section config.RuleSection + wantLen int + }{ + {config.SectionEstablished, 3}, + {config.SectionRelated, 3}, + {config.SectionInvalid, 3}, + {config.SectionUntracked, 3}, + {config.SectionNew, 3}, + {config.SectionAll, 0}, + {"", 0}, + } + + for _, tt := range tests { + exprs := matchSection(tt.section) + if len(exprs) != tt.wantLen { + t.Errorf("matchSection(%q) returned %d exprs, want %d", tt.section, len(exprs), tt.wantLen) + } + } +} + +func TestCompile_RuleSection(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + Section: config.SectionEstablished, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + if len(r.Exprs) < 6 { + t.Errorf("rule with section should have >= 6 exprs (iface+proto+dport+ctstate+verdict), got %d", len(r.Exprs)) + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestParseRateLimit(t *testing.T) { + tests := []struct { + input string + wantLen int + }{ + {"10/sec", 1}, + {"5/min", 1}, + {"100/hour", 1}, + {"1000/day", 1}, + {"s:10/sec:20", 1}, + {"invalid", 0}, + {"", 0}, + } + + for _, tt := range tests { + exprs := parseRateLimit(tt.input) + if len(exprs) != tt.wantLen { + t.Errorf("parseRateLimit(%q) returned %d exprs, want %d", tt.input, len(exprs), tt.wantLen) + } + } +} + +func TestCompile_RateLimit(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + RateLimit: "10/sec:5", + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + hasLimit := false + for _, e := range r.Exprs { + if _, ok := e.(*expr.Limit); ok { + hasLimit = true + } + } + if !hasLimit { + t.Error("rule with rate_limit should have Limit expression") + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestNegatedAddress(t *testing.T) { + exprs, err := matchSourceCIDR("!192.168.1.0/24") + if err != nil { + t.Fatalf("matchSourceCIDR(!192.168.1.0/24) error: %v", err) + } + if len(exprs) != 3 { + t.Fatalf("expected 3 exprs, got %d", len(exprs)) + } + cmp := exprs[2].(*expr.Cmp) + if cmp.Op != expr.CmpOpNeq { + t.Errorf("negated address should use CmpOpNeq, got %v", cmp.Op) + } + + exprs, err = matchDestCIDR("!10.0.0.1") + if err != nil { + t.Fatalf("matchDestCIDR(!10.0.0.1) error: %v", err) + } + if len(exprs) != 2 { + t.Fatalf("expected 2 exprs, got %d", len(exprs)) + } + cmp = exprs[1].(*expr.Cmp) + if cmp.Op != expr.CmpOpNeq { + t.Errorf("negated address should use CmpOpNeq, got %v", cmp.Op) + } +} + +func TestRejectTCPRST(t *testing.T) { + exprs := rejectExprs("tcp", config.FamilyINET) + if len(exprs) != 1 { + t.Fatalf("expected 1 expr, got %d", len(exprs)) + } + rej := exprs[0].(*expr.Reject) + if rej.Type != 1 { + t.Errorf("TCP reject should use NFT_REJECT_TCP_RST (1), got %d", rej.Type) + } + + exprs = rejectExprs("udp", config.FamilyINET) + rej = exprs[0].(*expr.Reject) + if rej.Type != 2 { + t.Errorf("non-TCP reject should use NFT_REJECT_ICMPX_UNREACH (2), got %d", rej.Type) + } +} + +func TestCompile_DHCP(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0", Options: config.InterfaceOptions{DHCP: true}}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + foundIn := false + foundOut := false + for _, r := range state.Rules["input"] { + if r.Tag == "dhcp:in:eth0" || r.Tag == "dhcp:reply:eth0" { + foundIn = true + } + } + for _, r := range state.Rules["output"] { + if r.Tag == "dhcp:out:eth0" { + foundOut = true + } + } + if !foundIn { + t.Error("no DHCP input rule found for eth0") + } + if !foundOut { + t.Error("no DHCP output rule found for eth0") + } +} + +func TestMatchUID(t *testing.T) { + exprs := matchUID("1000") + if len(exprs) != 2 { + t.Fatalf("matchUID(1000) returned %d exprs, want 2", len(exprs)) + } + cmp := exprs[1].(*expr.Cmp) + if cmp.Op != expr.CmpOpEq { + t.Error("non-negated UID should use CmpOpEq") + } + + exprs = matchUID("!0") + if len(exprs) != 2 { + t.Fatalf("matchUID(!0) returned %d exprs, want 2", len(exprs)) + } + cmp = exprs[1].(*expr.Cmp) + if cmp.Op != expr.CmpOpNeq { + t.Error("negated UID should use CmpOpNeq") + } +} + +func TestMatchMark(t *testing.T) { + exprs := matchMark("0x10/0xff") + if len(exprs) != 3 { + t.Fatalf("matchMark(0x10/0xff) returned %d exprs, want 3 (load+bitwise+cmp)", len(exprs)) + } + + exprs = matchMark("42") + if len(exprs) != 2 { + t.Fatalf("matchMark(42) returned %d exprs, want 2 (load+cmp)", len(exprs)) + } + + exprs = matchMark("!5") + cmp := exprs[1].(*expr.Cmp) + if cmp.Op != expr.CmpOpNeq { + t.Error("negated mark should use CmpOpNeq") + } +} + +func TestSetMarkExprs(t *testing.T) { + exprs := setMarkExprs("0x10") + if len(exprs) != 2 { + t.Fatalf("setMarkExprs(0x10) returned %d exprs, want 2", len(exprs)) + } + + exprs = setMarkExprs("0x10/0xff00") + if len(exprs) != 3 { + t.Fatalf("setMarkExprs(0x10/0xff00) returned %d exprs, want 3", len(exprs)) + } +} + +func TestCompile_Loopback(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + foundInput := false + foundOutput := false + for _, r := range state.Rules["input"] { + if r.Tag == "loopback:input" { + foundInput = true + } + } + for _, r := range state.Rules["output"] { + if r.Tag == "loopback:output" { + foundOutput = true + } + } + if !foundInput { + t.Error("loopback:input rule not found") + } + if !foundOutput { + t.Error("loopback:output rule not found") + } +} + +func TestCompile_AntiSpoof(t *testing.T) { + tcpflags := true + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0", Options: config.InterfaceOptions{ + NoSmurfs: true, + TCPFlags: &tcpflags, + }}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + foundSmurf := false + foundFlags := false + for _, r := range state.Rules["input"] { + if r.Tag == "antismurf:eth0" { + foundSmurf = true + } + if r.Tag == "tcpflags:eth0" { + foundFlags = true + } + } + if !foundSmurf { + t.Error("antismurf rule not found") + } + if !foundFlags { + t.Error("tcpflags rule not found") + } +} + +func TestCompile_MSSClamp(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "loc": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "loc", Interface: "eth1", Options: config.InterfaceOptions{MSS: 1400}}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["forward"] { + if r.Tag == "mss:eth1" { + found = true + } + } + if !found { + t.Error("MSS clamp rule not found in forward chain") + } +} + +func TestCompile_PolicyExclusion(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + "loc": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + {Zone: "loc", Interface: "eth1"}, + }, + Policy: []config.Policy{ + {Source: "all!net", Dest: "all", Action: config.PolicyAccept}, + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["forward"] { + if r.Tag == "policy:0" { + return + } + } + for _, r := range state.Rules["input"] { + if r.Tag == "policy:0" { + return + } + } + for _, r := range state.Rules["output"] { + if r.Tag == "policy:0" { + return + } + } + t.Error("policy:0 (all!net exclusion) not found in any chain") +} + +func TestMatchICMPType(t *testing.T) { + tests := []struct { + input string + wantLen int + }{ + {"echo-request", 2}, + {"8", 2}, + {"3/4", 4}, + {"destination-unreachable", 2}, + } + + for _, tt := range tests { + exprs := matchICMPType(tt.input) + if len(exprs) != tt.wantLen { + t.Errorf("matchICMPType(%q) returned %d exprs, want %d", tt.input, len(exprs), tt.wantLen) + } + } +} + +func TestCompile_ICMPRule(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "icmp", + DPort: config.PortSpec{"echo-request"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + if len(r.Exprs) < 5 { + t.Errorf("ICMP rule should have >= 5 exprs (iface+proto+icmptype+verdict), got %d", len(r.Exprs)) + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestMatchConnLimit(t *testing.T) { + exprs := matchConnLimit("20") + if len(exprs) != 1 { + t.Fatalf("matchConnLimit(20) returned %d exprs, want 1", len(exprs)) + } + cl := exprs[0].(*expr.Connlimit) + if cl.Count != 20 { + t.Errorf("Connlimit.Count = %d, want 20", cl.Count) + } + + exprs = matchConnLimit("d:10") + cl = exprs[0].(*expr.Connlimit) + if cl.Flags != 1 { + t.Errorf("d: prefix should set Flags=1, got %d", cl.Flags) + } +} + +func TestCompile_ConnLimit(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + ConnLimit: "20", + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + hasConnLimit := false + for _, e := range r.Exprs { + if _, ok := e.(*expr.Connlimit); ok { + hasConnLimit = true + } + } + if !hasConnLimit { + t.Error("rule with conn_limit should have Connlimit expression") + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestCompile_NFQUEUE(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleNFQueue, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"80"}, + NFQueue: 1, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + hasQueue := false + for _, e := range r.Exprs { + if q, ok := e.(*expr.Queue); ok { + hasQueue = true + if q.Num != 1 { + t.Errorf("Queue.Num = %d, want 1", q.Num) + } + } + } + if !hasQueue { + t.Error("NFQUEUE rule should have Queue expression") + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestCompile_NONAT(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + "loc": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + {Zone: "loc", Interface: "eth1"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleNoNAT, + Source: "net", + Dest: "loc", + Proto: "tcp", + DPort: config.PortSpec{"80"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["forward"] { + if r.Tag == "rule:0" { + hasReturn := false + for _, e := range r.Exprs { + if v, ok := e.(*expr.Verdict); ok && v.Kind == expr.VerdictReturn { + hasReturn = true + } + } + if !hasReturn { + t.Error("NONAT rule should have RETURN verdict") + } + return + } + } + t.Error("rule:0 not found in forward chain") +} + +func TestCompile_PolicyRateLimit(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "net", Dest: "fw", Action: config.PolicyDrop, RateLimit: "5/sec"}, + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "policy:0" { + hasLimit := false + for _, e := range r.Exprs { + if _, ok := e.(*expr.Limit); ok { + hasLimit = true + } + } + if !hasLimit { + t.Error("policy with rate_limit should have Limit expression") + } + return + } + } + t.Error("policy:0 not found in input chain") +} diff --git a/internal/nftables/diff.go b/internal/nftables/diff.go index 2232a3e..82cfa30 100644 --- a/internal/nftables/diff.go +++ b/internal/nftables/diff.go @@ -64,7 +64,13 @@ func computeDiff(current, desired *FirewallState) *ChangeSet { } for tag, desiredRules := range desiredByTag { - if _, exists := currentByTag[tag]; !exists { + currentRules, exists := currentByTag[tag] + if !exists { + cs.Add = append(cs.Add, desiredRules...) + continue + } + if !rulesMatch(currentRules, desiredRules) { + cs.Remove = append(cs.Remove, currentRules...) cs.Add = append(cs.Add, desiredRules...) } } @@ -77,3 +83,27 @@ func computeDiff(current, desired *FirewallState) *ChangeSet { return cs } + +func rulesMatch(a, b []ManagedRule) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Chain != b[i].Chain { + return false + } + if !exprsEqual(a[i].Exprs, b[i].Exprs) { + return false + } + } + return true +} + +func exprsEqual(a, b []expr.Any) bool { + if len(a) != len(b) { + return false + } + as := fmt.Sprintf("%v", a) + bs := fmt.Sprintf("%v", b) + return as == bs +} diff --git a/internal/shorewall/convert.go b/internal/shorewall/convert.go new file mode 100644 index 0000000..7ce0f74 --- /dev/null +++ b/internal/shorewall/convert.go @@ -0,0 +1,1445 @@ +package shorewall + +import ( + "fmt" + "strconv" + "strings" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +// Convert reads a shorewall or shorewall6 config directory and returns a tomswall Config. +// Auto-detects IPv6 mode when shorewall6.conf is present. +func Convert(dir string) (*config.Config, error) { + ipv6 := IsIPv6Dir(dir) + return convertDir(dir, ipv6) +} + +func convertDir(dir string, ipv6 bool) (*config.Config, error) { + cfg := &config.Config{ + Zones: make(map[string]config.Zone), + PortGroups: make(map[string]config.PortGroup), + } + + params, _ := ParseParams(dir + "/params") + if params == nil { + params = make(map[string]string) + } + + if err := convertConf(dir, cfg, params, ipv6); err != nil { + confName := "shorewall.conf" + if ipv6 { + confName = "shorewall6.conf" + } + return nil, fmt.Errorf("%s: %w", confName, err) + } + if err := convertZones(dir, cfg, params); err != nil { + return nil, fmt.Errorf("zones: %w", err) + } + + // $FW is a shorewall built-in that refers to the firewall zone + if _, ok := params["FW"]; !ok { + for name, z := range cfg.Zones { + if z.Type == config.ZoneFirewall { + params["FW"] = name + break + } + } + } + if len(params) > 0 { + cfg.Vars = params + } + + // Shared config files (identical format for shorewall and shorewall6) + converters := []struct { + name string + fn func(string, *config.Config, map[string]string) error + }{ + {"interfaces", convertInterfaces}, + {"hosts", convertHosts}, + {"policy", convertPolicy}, + {"rules", convertRules}, + {"snat", convertSNAT}, + {"nat", convertNAT}, + {"netmap", convertNetmap}, + {"providers", convertProviders}, + {"conntrack", convertConntrack}, + {"blrules", convertBlrules}, + {"tunnels", convertTunnels}, + {"rtrules", convertRtrules}, + {"stoppedrules", convertStoppedRules}, + {"mangle", convertMangle}, + {"accounting", convertAccounting}, + {"maclist", convertMaclist}, + {"routes", convertRoutes}, + {"tcdevices", convertTCDevices}, + {"tcclasses", convertTCClasses}, + {"tcinterfaces", convertTCInterfaces}, + {"tcpri", convertTCPri}, + {"secmarks", convertSecmarks}, + } + + for _, c := range converters { + if err := c.fn(dir, cfg, params); err != nil { + return nil, fmt.Errorf("%s: %w", c.name, err) + } + } + + // Family-specific converters + if ipv6 { + if err := convertProxyNDP(dir, cfg, params); err != nil { + return nil, fmt.Errorf("proxyndp: %w", err) + } + } else { + if err := convertProxyARP(dir, cfg, params); err != nil { + return nil, fmt.Errorf("proxyarp: %w", err) + } + } + + return cfg, nil +} + +func subst(s string, params map[string]string) string { + if !strings.Contains(s, "$") { + return s + } + return config.SubstituteVars(s, params) +} + +func convertConf(dir string, cfg *config.Config, params map[string]string, ipv6 bool) error { + confFile := dir + "/shorewall.conf" + if ipv6 { + confFile = dir + "/shorewall6.conf" + } + conf, err := ParseConf(confFile) + if err != nil { + return err + } + if conf == nil { + return nil + } + + cfg.Settings.TableName = "tomswall" + if ipv6 { + cfg.Settings.AddressFamily = config.FamilyIP6 + } else { + cfg.Settings.AddressFamily = config.FamilyIP + } + if v, ok := conf["LOG_LEVEL"]; ok && v != "" { + cfg.Settings.LogLevel = strings.ToLower(v) + } else { + cfg.Settings.LogLevel = "info" + } + if v, ok := conf["IP_FORWARDING"]; ok { + cfg.Settings.IPForwarding = v == "Yes" || v == "On" || v == "on" || v == "Keep" + } + if v, ok := conf["IMPLICIT_CONTINUE"]; ok { + cfg.Settings.ImplicitContinue = v == "Yes" + } + + return nil +} + +func convertZones(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/zones") + if err != nil { + return err + } + + for _, row := range rows { + name := subst(field(row, 0), params) + typeStr := subst(field(row, 1), params) + + var parents []string + if idx := strings.IndexByte(name, ':'); idx > 0 { + parentStr := name[idx+1:] + name = name[:idx] + for _, p := range strings.Split(parentStr, ",") { + p = strings.TrimSpace(p) + if p != "" { + parents = append(parents, p) + } + } + } + + zone := config.Zone{ + Type: convertZoneType(typeStr), + } + if len(parents) > 0 { + zone.Parents = parents + } + + opts := subst(field(row, 2), params) + if !isDash(opts) { + zone.Options = splitOptions(opts) + } + inOpts := subst(field(row, 3), params) + if !isDash(inOpts) { + zone.InOptions = splitOptions(inOpts) + } + outOpts := subst(field(row, 4), params) + if !isDash(outOpts) { + zone.OutOptions = splitOptions(outOpts) + } + + cfg.Zones[name] = zone + } + return nil +} + +func convertZoneType(t string) config.ZoneType { + switch strings.ToLower(strings.TrimSpace(t)) { + case "firewall", "fw": + return config.ZoneFirewall + case "ipv4", "ip", "ipv6": + return config.ZoneIP + case "ipsec", "ipsec4", "ipsec6": + return config.ZoneIPSec + case "bport", "bport4", "bport6": + return config.ZoneBPort + case "loopback": + return config.ZoneLoopback + case "local": + return config.ZoneLocal + default: + return config.ZoneIP + } +} + +func splitOptions(s string) []string { + var opts []string + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + if o != "" { + opts = append(opts, o) + } + } + return opts +} + +func convertInterfaces(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/interfaces") + if err != nil { + return err + } + + for _, row := range rows { + zone := subst(field(row, 0), params) + iface := subst(field(row, 1), params) + + intf := config.Interface{ + Zone: zone, + Interface: iface, + } + + optsStr := subst(field(row, 2), params) + if !isDash(optsStr) { + intf.Options = parseInterfaceOptions(optsStr) + } + + cfg.Interfaces = append(cfg.Interfaces, intf) + } + return nil +} + +func parseInterfaceOptions(s string) config.InterfaceOptions { + var opts config.InterfaceOptions + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + if o == "" { + continue + } + key, val := splitKV(o) + switch key { + case "dhcp": + opts.DHCP = true + case "tcpflags": + b := true + opts.TCPFlags = &b + case "nosmurfs": + opts.NoSmurfs = true + case "routeback": + b := true + opts.RouteBack = &b + case "bridge": + opts.Bridge = true + case "destonly": + opts.DestOnly = true + case "optional": + opts.Optional = true + case "required": + opts.Required = true + case "physical": + opts.Physical = val + case "routefilter": + if val != "" { + n, _ := strconv.Atoi(val) + opts.RouteFilter = &n + } else { + n := 1 + opts.RouteFilter = &n + } + case "logmartians": + b := true + opts.LogMartians = &b + case "arp_filter": + b := true + opts.ArpFilter = &b + case "arp_ignore": + if val != "" { + n, _ := strconv.Atoi(val) + opts.ArpIgnore = &n + } + case "proxyarp": + b := true + opts.ProxyArp = &b + case "sourceroute": + b := true + opts.SourceRoute = &b + case "upnp": + opts.Upnp = true + case "wait": + if val != "" { + n, _ := strconv.Atoi(val) + opts.Wait = n + } else { + opts.Wait = 1 + } + case "mss": + if val != "" { + n, _ := strconv.Atoi(val) + opts.MSS = n + } + case "nets": + if val != "" { + for _, n := range strings.Split(val, ",") { + n = strings.TrimSpace(n) + if n != "" { + opts.Nets = append(opts.Nets, n) + } + } + } + case "unmanaged": + opts.Unmanaged = true + case "accept_ra": + if val != "" { + n, _ := strconv.Atoi(val) + opts.AcceptRA = &n + } else { + n := 1 + opts.AcceptRA = &n + } + } + } + return opts +} + +func splitHelperChain(s string) (string, string) { + idx := strings.LastIndexByte(s, ':') + if idx < 0 { + return s, "" + } + suffix := s[idx+1:] + suffixLower := strings.ToLower(suffix) + switch suffixLower { + case "p": + return s[:idx], "prerouting" + case "o": + return s[:idx], "output" + case "po", "op": + return s[:idx], "both" + } + return s, "" +} + +func splitKV(s string) (string, string) { + idx := strings.IndexByte(s, '=') + if idx < 0 { + return strings.ToLower(s), "" + } + return strings.ToLower(s[:idx]), s[idx+1:] +} + +func convertHosts(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/hosts") + if err != nil { + return err + } + + for _, row := range rows { + zone := subst(field(row, 0), params) + hostDef := subst(field(row, 1), params) + + iface, addrs := splitHostDef(hostDef) + + host := config.Host{ + Zone: zone, + Interface: iface, + Addresses: addrs, + } + + optsStr := subst(field(row, 2), params) + if !isDash(optsStr) { + host.Options = parseHostOptions(optsStr) + } + + cfg.Hosts = append(cfg.Hosts, host) + } + return nil +} + +func splitHostDef(s string) (string, []string) { + idx := strings.IndexByte(s, ':') + if idx < 0 { + return s, nil + } + iface := s[:idx] + addrPart := s[idx+1:] + var addrs []string + for _, a := range strings.Split(addrPart, ",") { + a = strings.TrimSpace(a) + if a != "" { + addrs = append(addrs, a) + } + } + return iface, addrs +} + +func parseHostOptions(s string) config.HostOptions { + var opts config.HostOptions + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + switch strings.ToLower(o) { + case "broadcast": + opts.Broadcast = true + case "destonly": + opts.DestOnly = true + case "ipsec": + opts.IPSec = true + case "nosmurfs": + opts.NoSmurfs = true + case "routeback": + opts.RouteBack = true + case "tcpflags": + opts.TCPFlags = true + } + key, val := splitKV(o) + if key == "mss" && val != "" { + n, _ := strconv.Atoi(val) + opts.MSS = n + } + } + return opts +} + +func convertPolicy(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/policy") + if err != nil { + return err + } + + for _, row := range rows { + source := subst(field(row, 0), params) + dest := subst(field(row, 1), params) + action := subst(field(row, 2), params) + logLevel := subst(field(row, 3), params) + + pol := config.Policy{ + Source: strings.ToLower(source), + Dest: strings.ToLower(dest), + Action: config.PolicyAction(strings.ToLower(action)), + } + if !isDash(logLevel) { + pol.Log = logLevel + } + + burstLimit := subst(field(row, 4), params) + if !isDash(burstLimit) { + pol.RateLimit = burstLimit + } + connLimit := subst(field(row, 5), params) + if !isDash(connLimit) { + pol.ConnLimit = connLimit + } + + cfg.Policy = append(cfg.Policy, pol) + } + return nil +} + +func convertRules(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/rules") + if err != nil { + return err + } + + currentSection := "" + for _, row := range rows { + if row[0] == "?SECTION" { + currentSection = strings.ToLower(field(row, 1)) + continue + } + + actionStr := subst(field(row, 0), params) + source := subst(field(row, 1), params) + dest := subst(field(row, 2), params) + proto := subst(field(row, 3), params) + + action, logLevel := splitActionLog(actionStr) + + rule := config.Rule{ + Action: config.RuleAction(strings.ToLower(action)), + Source: source, + Dest: dest, + } + if currentSection != "" && currentSection != "all" { + rule.Section = config.RuleSection(currentSection) + } + if logLevel != "" { + rule.Log = logLevel + } + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + + dport := subst(field(row, 4), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + origdest := subst(field(row, 6), params) + if !isDash(origdest) { + rule.OrigDest = origdest + } + rate := subst(field(row, 7), params) + if !isDash(rate) { + rule.RateLimit = rate + } + user := subst(field(row, 8), params) + if !isDash(user) { + rule.User = user + } + mark := subst(field(row, 9), params) + if !isDash(mark) { + rule.Mark = mark + } + connLimit := subst(field(row, 10), params) + if !isDash(connLimit) { + rule.ConnLimit = connLimit + } + helper := subst(field(row, 14), params) + if !isDash(helper) { + rule.Helper = helper + } + + cfg.Rules = append(cfg.Rules, rule) + } + return nil +} + +func splitActionLog(s string) (action, logLevel string) { + idx := strings.IndexByte(s, ':') + if idx < 0 { + return s, "" + } + return s[:idx], s[idx+1:] +} + +func parsePortSpec(s string) config.PortSpec { + var ports config.PortSpec + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + ports = append(ports, p) + } + } + return ports +} + +func convertSNAT(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/snat") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + action, logLevel := splitActionLog(actionStr) + + snat := config.SNATRule{ + Action: config.SNATAction(strings.ToLower(action)), + } + if logLevel != "" { + snat.Log = logLevel + } + + source := subst(field(row, 1), params) + if !isDash(source) { + snat.Source = source + } + dest := subst(field(row, 2), params) + if !isDash(dest) { + snat.Dest = dest + } + address := subst(field(row, 3), params) + if !isDash(address) { + snat.Address = address + } + proto := subst(field(row, 4), params) + if !isDash(proto) { + snat.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 5), params) + if !isDash(dport) { + snat.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 6), params) + if !isDash(sport) { + snat.SPort = parsePortSpec(sport) + } + mark := subst(field(row, 7), params) + if !isDash(mark) { + snat.Mark = mark + } + + cfg.SNAT = append(cfg.SNAT, snat) + } + return nil +} + +func convertNAT(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/nat") + if err != nil { + return err + } + + for _, row := range rows { + nat := config.StaticNAT{ + External: subst(field(row, 0), params), + Interface: subst(field(row, 1), params), + Internal: subst(field(row, 2), params), + } + allIfaces := subst(field(row, 3), params) + if strings.EqualFold(allIfaces, "yes") { + nat.AllInterfaces = true + } + local := subst(field(row, 4), params) + if strings.EqualFold(local, "yes") { + nat.Local = true + } + + cfg.StaticNAT = append(cfg.StaticNAT, nat) + } + return nil +} + +func convertNetmap(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/netmap") + if err != nil { + return err + } + + for _, row := range rows { + nm := config.Netmap{ + Type: config.NetmapType(strings.ToLower(subst(field(row, 0), params))), + Net1: subst(field(row, 1), params), + Interface: subst(field(row, 2), params), + Net2: subst(field(row, 3), params), + } + net3 := subst(field(row, 4), params) + if !isDash(net3) { + nm.Net3 = net3 + } + proto := subst(field(row, 5), params) + if !isDash(proto) { + nm.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 6), params) + if !isDash(dport) { + nm.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 7), params) + if !isDash(sport) { + nm.SPort = parsePortSpec(sport) + } + + cfg.Netmap = append(cfg.Netmap, nm) + } + return nil +} + +func convertProviders(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/providers") + if err != nil { + return err + } + + for _, row := range rows { + name := subst(field(row, 0), params) + numberStr := subst(field(row, 1), params) + number, _ := strconv.Atoi(numberStr) + + prov := config.Provider{ + Name: name, + Number: number, + } + + markStr := subst(field(row, 2), params) + if !isDash(markStr) { + mark, _ := strconv.ParseInt(markStr, 0, 64) + prov.Mark = int(mark) + } + dup := subst(field(row, 3), params) + if !isDash(dup) { + prov.Duplicate = dup + } + iface := subst(field(row, 4), params) + if !isDash(iface) { + prov.Interface = iface + } + gw := subst(field(row, 5), params) + if !isDash(gw) { + prov.Gateway = gw + } + optsStr := subst(field(row, 6), params) + if !isDash(optsStr) { + prov.Options = parseProviderOptions(optsStr) + } + copyStr := subst(field(row, 7), params) + if !isDash(copyStr) { + for _, c := range strings.Split(copyStr, ",") { + c = strings.TrimSpace(c) + if c != "" { + prov.Copy = append(prov.Copy, c) + } + } + } + + cfg.Providers = append(cfg.Providers, prov) + } + return nil +} + +func parseProviderOptions(s string) config.ProviderOptions { + var opts config.ProviderOptions + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + key, val := splitKV(o) + switch key { + case "track": + opts.Track = true + case "balance": + if val != "" { + n, _ := strconv.Atoi(val) + opts.Balance = n + } else { + opts.Balance = 1 + } + case "load": + if val != "" { + f, _ := strconv.ParseFloat(val, 64) + opts.Load = f + } + case "loose": + opts.Loose = true + case "fallback": + if val != "" { + n, _ := strconv.Atoi(val) + opts.Fallback = n + } else { + opts.Fallback = 1 + } + case "primary": + opts.Primary = true + case "src": + opts.Src = val + case "mtu": + if val != "" { + n, _ := strconv.Atoi(val) + opts.MTU = n + } + case "tproxy": + opts.TProxy = true + case "optional": + opts.Optional = true + case "persistent": + opts.Persistent = true + } + } + return opts +} + +func convertConntrack(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/conntrack") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + + ct := config.ConntrackRule{} + + actionLower := strings.ToLower(actionStr) + if strings.HasPrefix(actionLower, "ct:helper:") { + ct.Action = config.ConntrackHelper + helper := actionStr[10:] + helper, chain := splitHelperChain(helper) + ct.Helper = helper + if chain != "" { + ct.Chain = config.ConntrackChain(chain) + } + } else if strings.HasPrefix(actionLower, "ct:") { + ct.Action = config.ConntrackHelper + helper := actionStr[3:] + helper, chain := splitHelperChain(helper) + ct.Helper = helper + if chain != "" { + ct.Chain = config.ConntrackChain(chain) + } + } else { + ct.Action = config.ConntrackAction(actionLower) + } + + source := subst(field(row, 1), params) + if !isDash(source) { + ct.Source = source + } + dest := subst(field(row, 2), params) + if !isDash(dest) { + ct.Dest = dest + } + proto := subst(field(row, 3), params) + if !isDash(proto) { + ct.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 4), params) + if !isDash(dport) { + ct.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + ct.SPort = parsePortSpec(sport) + } + user := subst(field(row, 6), params) + if !isDash(user) { + ct.User = user + } + + cfg.Conntrack = append(cfg.Conntrack, ct) + } + return nil +} + +func convertBlrules(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/blrules") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + action, logLevel := splitActionLog(actionStr) + + rule := config.BlruleRule{ + Action: config.BlruleAction(strings.ToLower(action)), + Source: subst(field(row, 1), params), + Dest: subst(field(row, 2), params), + } + if logLevel != "" { + rule.Log = logLevel + } + proto := subst(field(row, 3), params) + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 4), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + + cfg.Blrules = append(cfg.Blrules, rule) + } + return nil +} + +func convertTunnels(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tunnels") + if err != nil { + return err + } + + for _, row := range rows { + typeStr := subst(field(row, 0), params) + zone := subst(field(row, 1), params) + gwStr := subst(field(row, 2), params) + + var gateways []string + for _, gw := range strings.Split(gwStr, ",") { + gw = strings.TrimSpace(gw) + if gw != "" { + gateways = append(gateways, gw) + } + } + + tunnel := config.Tunnel{ + Type: typeStr, + Zone: zone, + Gateways: gateways, + } + + gwZones := subst(field(row, 3), params) + if !isDash(gwZones) { + for _, gz := range strings.Split(gwZones, ",") { + gz = strings.TrimSpace(gz) + if gz != "" { + tunnel.GatewayZones = append(tunnel.GatewayZones, gz) + } + } + } + + cfg.Tunnels = append(cfg.Tunnels, tunnel) + } + return nil +} + +func convertRtrules(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/rtrules") + if err != nil { + return err + } + + for _, row := range rows { + source := subst(field(row, 0), params) + dest := subst(field(row, 1), params) + provider := subst(field(row, 2), params) + priorityStr := subst(field(row, 3), params) + priority, _ := strconv.Atoi(strings.TrimSuffix(priorityStr, "!")) + + rule := config.RoutingRule{ + Provider: provider, + Priority: priority, + } + if !isDash(source) { + rule.Source = source + } + if !isDash(dest) { + rule.Dest = dest + } + if strings.HasSuffix(priorityStr, "!") { + rule.Persistent = true + } + + mark := subst(field(row, 4), params) + if !isDash(mark) { + rule.Mark = mark + } + + cfg.RoutingRules = append(cfg.RoutingRules, rule) + } + return nil +} + +func convertStoppedRules(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/stoppedrules") + if err != nil { + return err + } + + for _, row := range rows { + action := subst(field(row, 0), params) + rule := config.StoppedRule{ + Action: config.StoppedAction(strings.ToLower(action)), + } + source := subst(field(row, 1), params) + if !isDash(source) { + rule.Source = source + } + dest := subst(field(row, 2), params) + if !isDash(dest) { + rule.Dest = dest + } + proto := subst(field(row, 3), params) + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 4), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + + cfg.StoppedRules = append(cfg.StoppedRules, rule) + } + return nil +} + +func convertMangle(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/mangle") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + + rule := config.MangleRule{} + + actionParts := strings.SplitN(actionStr, "(", 2) + base := actionParts[0] + + var chain string + if idx := strings.LastIndexByte(base, ':'); idx > 0 { + chain = strings.ToLower(base[idx+1:]) + base = base[:idx] + } + + markVal := "" + if len(actionParts) > 1 { + markVal = strings.TrimSuffix(actionParts[1], ")") + } + + rule.Action = config.MangleAction(strings.ToLower(base)) + if chain != "" { + rule.Chain = config.MangleChain(chain) + } + if markVal != "" { + rule.MarkValue = markVal + } + + source := subst(field(row, 1), params) + if !isDash(source) { + rule.Source = source + } + dest := subst(field(row, 2), params) + if !isDash(dest) { + rule.Dest = dest + } + proto := subst(field(row, 3), params) + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 4), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + user := subst(field(row, 6), params) + if !isDash(user) { + rule.User = user + } + test := subst(field(row, 7), params) + if !isDash(test) { + rule.Mark = test + } + length := subst(field(row, 8), params) + if !isDash(length) { + rule.Length = length + } + tos := subst(field(row, 9), params) + if !isDash(tos) { + rule.TOS = tos + } + helper := subst(field(row, 11), params) + if !isDash(helper) { + rule.Helper = helper + } + prob := subst(field(row, 12), params) + if !isDash(prob) { + f, _ := strconv.ParseFloat(prob, 64) + rule.Probability = f + } + dscp := subst(field(row, 13), params) + if !isDash(dscp) { + rule.DSCP = dscp + } + + cfg.Mangle = append(cfg.Mangle, rule) + } + return nil +} + +func convertAccounting(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/accounting") + if err != nil { + return err + } + + currentSection := "" + for _, row := range rows { + if row[0] == "?SECTION" { + currentSection = strings.ToLower(field(row, 1)) + continue + } + + actionStr := subst(field(row, 0), params) + + rule := config.AccountingRule{ + Action: config.AccountingAction(strings.ToLower(actionStr)), + } + if currentSection != "" { + rule.Section = config.AccountingSection(currentSection) + } + + chain := subst(field(row, 1), params) + if !isDash(chain) { + rule.Chain = chain + } + source := subst(field(row, 2), params) + if !isDash(source) { + rule.Source = source + } + dest := subst(field(row, 3), params) + if !isDash(dest) { + rule.Dest = dest + } + proto := subst(field(row, 4), params) + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 5), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 6), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + mark := subst(field(row, 8), params) + if !isDash(mark) { + rule.Mark = mark + } + + cfg.Accounting = append(cfg.Accounting, rule) + } + return nil +} + +func convertMaclist(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/maclist") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + action, logLevel := splitActionLog(actionStr) + + entry := config.MaclistEntry{ + Action: config.MaclistAction(strings.ToLower(action)), + Interface: subst(field(row, 1), params), + MAC: subst(field(row, 2), params), + } + if logLevel != "" { + entry.Log = logLevel + } + + addrs := subst(field(row, 3), params) + if !isDash(addrs) { + for _, a := range strings.Split(addrs, ",") { + a = strings.TrimSpace(a) + if a != "" { + entry.Addresses = append(entry.Addresses, a) + } + } + } + + cfg.Maclist = append(cfg.Maclist, entry) + } + return nil +} + +func convertProxyARP(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/proxyarp") + if err != nil { + return err + } + + for _, row := range rows { + entry := config.ProxyARP{ + Address: subst(field(row, 0), params), + Interface: subst(field(row, 1), params), + External: subst(field(row, 2), params), + } + haveRoute := subst(field(row, 3), params) + if strings.EqualFold(haveRoute, "yes") { + entry.HaveRoute = true + } + persistent := subst(field(row, 4), params) + if strings.EqualFold(persistent, "yes") { + entry.Persistent = true + } + + cfg.ProxyARP = append(cfg.ProxyARP, entry) + } + return nil +} + +func convertRoutes(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/routes") + if err != nil { + return err + } + + for _, row := range rows { + route := config.StaticRoute{ + Provider: subst(field(row, 0), params), + Dest: subst(field(row, 1), params), + } + gw := subst(field(row, 2), params) + if !isDash(gw) { + route.Gateway = gw + } + dev := subst(field(row, 3), params) + if !isDash(dev) { + route.Device = dev + } + + cfg.Routes = append(cfg.Routes, route) + } + return nil +} + +func convertTCDevices(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tcdevices") + if err != nil { + return err + } + + for _, row := range rows { + dev := config.TCDevice{ + Interface: subst(field(row, 0), params), + InBandwidth: subst(field(row, 1), params), + OutBandwidth: subst(field(row, 2), params), + } + if isDash(dev.InBandwidth) { + dev.InBandwidth = "" + } + + opts := subst(field(row, 3), params) + if !isDash(opts) { + dev.Options = parseTCDeviceOptions(opts) + } + + cfg.TCDevices = append(cfg.TCDevices, dev) + } + return nil +} + +func parseTCDeviceOptions(s string) config.TCDeviceOptions { + var opts config.TCDeviceOptions + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + switch strings.ToLower(o) { + case "classify": + opts.Classify = true + case "htb": + opts.HTB = true + case "hfsc": + opts.HFSC = true + default: + key, val := splitKV(o) + if key == "linklayer" { + opts.Linklayer = val + } + } + } + return opts +} + +func convertTCClasses(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tcclasses") + if err != nil { + return err + } + + for _, row := range rows { + cls := config.TCClass{ + Interface: subst(field(row, 0), params), + Rate: subst(field(row, 2), params), + } + markStr := subst(field(row, 1), params) + if !isDash(markStr) { + mark, _ := strconv.Atoi(markStr) + cls.Mark = mark + } + ceil := subst(field(row, 3), params) + if !isDash(ceil) { + cls.Ceil = ceil + } + priStr := subst(field(row, 4), params) + if !isDash(priStr) { + pri, _ := strconv.Atoi(priStr) + cls.Priority = pri + } + opts := subst(field(row, 5), params) + if !isDash(opts) { + cls.Options = parseTCClassOptions(opts) + } + + cfg.TCClasses = append(cfg.TCClasses, cls) + } + return nil +} + +func parseTCClassOptions(s string) config.TCClassOptions { + var opts config.TCClassOptions + for _, o := range strings.Split(s, ",") { + switch strings.ToLower(strings.TrimSpace(o)) { + case "default": + opts.Default = true + case "tcp-ack": + opts.TCPAck = true + case "pfifo": + opts.Pfifo = true + } + } + return opts +} + +func convertTCInterfaces(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tcinterfaces") + if err != nil { + return err + } + + for _, row := range rows { + tci := config.TCInterface{ + Interface: subst(field(row, 0), params), + } + typeStr := subst(field(row, 1), params) + if !isDash(typeStr) { + tci.Type = strings.ToLower(typeStr) + } + inBw := subst(field(row, 2), params) + if !isDash(inBw) { + tci.InBandwidth = inBw + } + outBw := subst(field(row, 3), params) + if !isDash(outBw) { + tci.OutBandwidth = outBw + } + + cfg.TCInterfaces = append(cfg.TCInterfaces, tci) + } + return nil +} + +func convertTCPri(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tcpri") + if err != nil { + return err + } + + for _, row := range rows { + bandStr := subst(field(row, 0), params) + band, _ := strconv.Atoi(bandStr) + + tp := config.TCPriority{ + Band: band, + } + proto := subst(field(row, 1), params) + if !isDash(proto) { + tp.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 2), params) + if !isDash(dport) { + tp.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 3), params) + if !isDash(sport) { + tp.SPort = parsePortSpec(sport) + } + addr := subst(field(row, 4), params) + if !isDash(addr) { + tp.Address = addr + } + iface := subst(field(row, 5), params) + if !isDash(iface) { + tp.Interface = iface + } + helper := subst(field(row, 6), params) + if !isDash(helper) { + tp.Helper = helper + } + + cfg.TCPriorities = append(cfg.TCPriorities, tp) + } + return nil +} + +func convertSecmarks(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/secmarks") + if err != nil { + return err + } + + for _, row := range rows { + sm := config.SecmarkRule{ + Secmark: subst(field(row, 0), params), + Chain: subst(field(row, 1), params), + } + source := subst(field(row, 2), params) + if !isDash(source) { + sm.Source = source + } + dest := subst(field(row, 3), params) + if !isDash(dest) { + sm.Dest = dest + } + proto := subst(field(row, 4), params) + if !isDash(proto) { + sm.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 5), params) + if !isDash(dport) { + sm.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 6), params) + if !isDash(sport) { + sm.SPort = parsePortSpec(sport) + } + + cfg.Secmarks = append(cfg.Secmarks, sm) + } + return nil +} + +func convertProxyNDP(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/proxyndp") + if err != nil { + return err + } + + for _, row := range rows { + entry := config.ProxyNDP{ + Address: subst(field(row, 0), params), + Interface: subst(field(row, 1), params), + External: subst(field(row, 2), params), + } + haveRoute := subst(field(row, 3), params) + if strings.EqualFold(haveRoute, "yes") { + entry.HaveRoute = true + } + persistent := subst(field(row, 4), params) + if strings.EqualFold(persistent, "yes") { + entry.Persistent = true + } + + cfg.ProxyNDP = append(cfg.ProxyNDP, entry) + } + return nil +} diff --git a/internal/shorewall/convert_test.go b/internal/shorewall/convert_test.go new file mode 100644 index 0000000..5b4b49c --- /dev/null +++ b/internal/shorewall/convert_test.go @@ -0,0 +1,727 @@ +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") + } + }) +} diff --git a/internal/shorewall/parser.go b/internal/shorewall/parser.go new file mode 100644 index 0000000..cb8242b --- /dev/null +++ b/internal/shorewall/parser.go @@ -0,0 +1,172 @@ +package shorewall + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ParseFile reads a shorewall columnar config file and returns rows of fields. +// Handles comments (#), blank lines, line continuation (\), and ?COMMENT directives. +func ParseFile(path string) ([][]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("opening %s: %w", path, err) + } + defer f.Close() + + var rows [][]string + var continuation string + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := scanner.Text() + + if strings.HasSuffix(line, "\\") { + continuation += strings.TrimSuffix(line, "\\") + " " + continue + } + if continuation != "" { + line = continuation + line + continuation = "" + } + + line = strings.TrimSpace(line) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "SECTION") || strings.HasPrefix(line, "?SECTION") { + raw := strings.TrimPrefix(line, "?") + rows = append(rows, []string{"?SECTION", strings.TrimSpace(strings.TrimPrefix(raw, "SECTION"))}) + continue + } + + if strings.HasPrefix(line, "?") { + continue + } + + fields := splitFields(line) + if len(fields) > 0 { + rows = append(rows, fields) + } + } + + return rows, scanner.Err() +} + +// splitFields splits a shorewall config line into fields. +// Fields are whitespace-separated, but supports the { key=value ... } alternate syntax. +func splitFields(line string) []string { + var fields []string + for _, f := range strings.Fields(line) { + if f == "#" { + break + } + if strings.HasPrefix(f, "#") { + break + } + fields = append(fields, f) + } + return fields +} + +// ParseConf reads a shorewall.conf (key=value) file into a map. +func ParseConf(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + conf := make(map[string]string) + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + val = strings.Trim(val, "\"'") + if key != "" { + conf[key] = val + } + } + return conf, scanner.Err() +} + +// ParseParams reads a shorewall params file and extracts variable assignments. +// This is simplified — it handles VAR=value lines but not full shell evaluation. +func ParseParams(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + + params := make(map[string]string) + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + val = strings.Trim(val, "\"'") + if key != "" && !strings.ContainsAny(key, " \t$(){}") { + params[key] = val + } + } + return params, scanner.Err() +} + +// field returns the nth field (0-based) from a row, or "-" if missing. +func field(row []string, n int) string { + if n >= len(row) { + return "-" + } + return row[n] +} + +// isDash returns true if the field is empty or a dash. +func isDash(s string) bool { + return s == "" || s == "-" +} + +// DirExists checks if a shorewall or shorewall6 config directory looks valid. +func DirExists(dir string) bool { + for _, name := range []string{"zones", "shorewall.conf", "shorewall6.conf"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err == nil && !info.IsDir() { + return true + } + } + return false +} + +// IsIPv6Dir returns true if the directory contains a shorewall6 config. +func IsIPv6Dir(dir string) bool { + info, err := os.Stat(filepath.Join(dir, "shorewall6.conf")) + return err == nil && !info.IsDir() +} diff --git a/internal/shorewall/parser_test.go b/internal/shorewall/parser_test.go new file mode 100644 index 0000000..1a2a516 --- /dev/null +++ b/internal/shorewall/parser_test.go @@ -0,0 +1,413 @@ +package shorewall + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseFile_Basic(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "zones") + + content := `# This is a comment +fw firewall + +net ipv4 +loc ipv4 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + if rows[0][0] != "fw" || rows[0][1] != "firewall" { + t.Errorf("row 0 = %v, want [fw firewall]", rows[0]) + } + if rows[1][0] != "net" || rows[1][1] != "ipv4" { + t.Errorf("row 1 = %v, want [net ipv4]", rows[1]) + } +} + +func TestParseFile_BlankLinesAndComments(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test") + + content := ` +# full line comment + # indented comment + +field1 field2 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0][0] != "field1" || rows[0][1] != "field2" { + t.Errorf("row 0 = %v, want [field1 field2]", rows[0]) + } +} + +func TestParseFile_Continuation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test") + + content := `first \ +second third +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if len(rows[0]) != 3 { + t.Fatalf("expected 3 fields, got %d: %v", len(rows[0]), rows[0]) + } + if rows[0][0] != "first" || rows[0][1] != "second" || rows[0][2] != "third" { + t.Errorf("row 0 = %v, want [first second third]", rows[0]) + } +} + +func TestParseFile_QuestionDirective(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test") + + content := `?COMMENT this is a comment directive +field1 field2 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + // ?COMMENT lines should be skipped (starts with ?) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d: %v", len(rows), rows) + } + if rows[0][0] != "field1" { + t.Errorf("expected field1, got %s", rows[0][0]) + } +} + +func TestParseFile_SectionMarker(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test") + + // Note: ?SECTION lines are caught by the generic "?" prefix handler + // before the SECTION check, so only bare SECTION lines produce markers. + content := `SECTION NEW +ACCEPT net fw tcp 22 +SECTION ESTABLISHED +ACCEPT all all +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if len(rows) != 4 { + t.Fatalf("expected 4 rows, got %d: %v", len(rows), rows) + } + + // First row should be the SECTION marker + if rows[0][0] != "?SECTION" || rows[0][1] != "NEW" { + t.Errorf("row 0 = %v, want [?SECTION NEW]", rows[0]) + } + + // Second row is a regular rule + if rows[1][0] != "ACCEPT" { + t.Errorf("row 1[0] = %s, want ACCEPT", rows[1][0]) + } + + // Third row is SECTION ESTABLISHED + if rows[2][0] != "?SECTION" || rows[2][1] != "ESTABLISHED" { + t.Errorf("row 2 = %v, want [?SECTION ESTABLISHED]", rows[2]) + } + + // Fourth row is the rule + if rows[3][0] != "ACCEPT" { + t.Errorf("row 3[0] = %s, want ACCEPT", rows[3][0]) + } +} + +func TestParseFile_NotExist(t *testing.T) { + rows, err := ParseFile("/nonexistent/path/zones") + if err != nil { + t.Fatalf("expected nil error for nonexistent file, got %v", err) + } + if rows != nil { + t.Fatalf("expected nil rows, got %v", rows) + } +} + +func TestParseConf(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "shorewall.conf") + + content := `# Shorewall config +IP_FORWARDING=Yes +LOG_LEVEL=info +STARTUP_ENABLED=Yes +QUOTED_VALUE="some value" +SINGLE_QUOTED='another' +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + conf, err := ParseConf(path) + if err != nil { + t.Fatalf("ParseConf: %v", err) + } + + tests := map[string]string{ + "IP_FORWARDING": "Yes", + "LOG_LEVEL": "info", + "STARTUP_ENABLED": "Yes", + "QUOTED_VALUE": "some value", + "SINGLE_QUOTED": "another", + } + for k, want := range tests { + got, ok := conf[k] + if !ok { + t.Errorf("key %q not found in conf", k) + continue + } + if got != want { + t.Errorf("conf[%q] = %q, want %q", k, got, want) + } + } +} + +func TestParseConf_CommentsAndBlanks(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "conf") + + content := ` +# comment +KEY1=val1 + +# another comment +KEY2=val2 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + conf, err := ParseConf(path) + if err != nil { + t.Fatalf("ParseConf: %v", err) + } + if len(conf) != 2 { + t.Fatalf("expected 2 entries, got %d", len(conf)) + } +} + +func TestParseConf_NoEquals(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "conf") + + content := `NOEQUALS +KEY=val +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + conf, err := ParseConf(path) + if err != nil { + t.Fatalf("ParseConf: %v", err) + } + if len(conf) != 1 { + t.Fatalf("expected 1 entry (lines without = skipped), got %d", len(conf)) + } + if conf["KEY"] != "val" { + t.Errorf("conf[KEY] = %q, want %q", conf["KEY"], "val") + } +} + +func TestParseParams(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "params") + + content := `# params file +NET_IF=eth0 +LOC_IF=eth1 +NET_ADDR=192.168.1.0/24 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + params, err := ParseParams(path) + if err != nil { + t.Fatalf("ParseParams: %v", err) + } + if params["NET_IF"] != "eth0" { + t.Errorf("NET_IF = %q, want %q", params["NET_IF"], "eth0") + } + if params["LOC_IF"] != "eth1" { + t.Errorf("LOC_IF = %q, want %q", params["LOC_IF"], "eth1") + } + if params["NET_ADDR"] != "192.168.1.0/24" { + t.Errorf("NET_ADDR = %q, want %q", params["NET_ADDR"], "192.168.1.0/24") + } +} + +func TestParseParams_NotExist(t *testing.T) { + params, err := ParseParams("/nonexistent/params") + if err != nil { + t.Fatalf("expected nil error for nonexistent file, got %v", err) + } + if params != nil { + t.Fatalf("expected nil params, got %v", params) + } +} + +func TestParseParams_SkipsShellSyntax(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "params") + + content := `GOOD_VAR=value +$(bad)=nope +KEY WITH SPACES=no +ALSO_GOOD=yes +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + params, err := ParseParams(path) + if err != nil { + t.Fatalf("ParseParams: %v", err) + } + if _, ok := params["$(bad)"]; ok { + t.Error("should skip key with shell metacharacters") + } + if params["GOOD_VAR"] != "value" { + t.Errorf("GOOD_VAR = %q, want %q", params["GOOD_VAR"], "value") + } + if params["ALSO_GOOD"] != "yes" { + t.Errorf("ALSO_GOOD = %q, want %q", params["ALSO_GOOD"], "yes") + } +} + +func TestSplitFields(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"ACCEPT net fw tcp 22", []string{"ACCEPT", "net", "fw", "tcp", "22"}}, + {"ACCEPT net fw # inline comment", []string{"ACCEPT", "net", "fw"}}, + {"ACCEPT net fw #comment", []string{"ACCEPT", "net", "fw"}}, + {"single", []string{"single"}}, + {" spaced out ", []string{"spaced", "out"}}, + } + + for _, tt := range tests { + got := splitFields(tt.input) + if len(got) != len(tt.want) { + t.Errorf("splitFields(%q) = %v (len %d), want %v (len %d)", + tt.input, got, len(got), tt.want, len(tt.want)) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitFields(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + } +} + +func TestIsDash(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"-", true}, + {"", true}, + {"eth0", false}, + {"tcp", false}, + {"--", false}, + } + + for _, tt := range tests { + got := isDash(tt.input) + if got != tt.want { + t.Errorf("isDash(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestDirExists(t *testing.T) { + t.Run("with zones file", func(t *testing.T) { + dir := t.TempDir() + zonesPath := filepath.Join(dir, "zones") + if err := os.WriteFile(zonesPath, []byte("fw firewall\n"), 0644); err != nil { + t.Fatal(err) + } + + if !DirExists(dir) { + t.Error("DirExists should return true when zones file exists") + } + }) + + t.Run("with shorewall.conf only", func(t *testing.T) { + dir := t.TempDir() + confPath := filepath.Join(dir, "shorewall.conf") + if err := os.WriteFile(confPath, []byte("IP_FORWARDING=Yes\n"), 0644); err != nil { + t.Fatal(err) + } + + if !DirExists(dir) { + t.Error("DirExists should return true when shorewall.conf exists") + } + }) + + t.Run("empty directory", func(t *testing.T) { + dir := t.TempDir() + + if DirExists(dir) { + t.Error("DirExists should return false for empty directory") + } + }) + + t.Run("zones is a directory not a file", func(t *testing.T) { + dir := t.TempDir() + zonesDir := filepath.Join(dir, "zones") + if err := os.Mkdir(zonesDir, 0755); err != nil { + t.Fatal(err) + } + + // zones exists but is a directory, and no shorewall.conf + if DirExists(dir) { + t.Error("DirExists should return false when zones is a directory and no shorewall.conf") + } + }) +} diff --git a/scripts/test-migration.sh b/scripts/test-migration.sh new file mode 100755 index 0000000..1f49f82 --- /dev/null +++ b/scripts/test-migration.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -euo pipefail + +TOMSWALL="$(dirname "$0")/../tomswall" +TMPDIR="$(mktemp -d /tmp/tomswall-test.XXXXXX)" +trap "rm -rf $TMPDIR" EXIT + +echo "=== tomswall migration test ===" +echo "Temp directory: $TMPDIR" +echo + +# Step 1: Save current iptables state +echo "--- Step 1: Saving current iptables/nftables state ---" +if command -v iptables-save &>/dev/null; then + iptables-save > "$TMPDIR/iptables-current.txt" 2>/dev/null || true +fi +if command -v nft &>/dev/null; then + nft list ruleset > "$TMPDIR/nft-current.txt" 2>/dev/null || true +fi +echo "Saved to $TMPDIR/iptables-current.txt and $TMPDIR/nft-current.txt" +echo + +# Step 2: Migrate shorewall config to YAML +echo "--- Step 2: Migrating /etc/shorewall to YAML ---" +"$TOMSWALL" migrate /etc/shorewall -o "$TMPDIR/migrated.yaml" 2>&1 +echo "Migrated config written to $TMPDIR/migrated.yaml" +echo + +# Step 3: Also output JSON for comparison +echo "--- Step 3: Migrating /etc/shorewall to JSON ---" +"$TOMSWALL" migrate /etc/shorewall -f json -o "$TMPDIR/migrated.json" 2>&1 +echo "JSON config written to $TMPDIR/migrated.json" +echo + +# Step 4: Validate the migrated config +echo "--- Step 4: Validating migrated YAML config ---" +"$TOMSWALL" validate -c "$TMPDIR/migrated.yaml" 2>&1 || true +echo + +# Step 5: Validate reading from shorewall directory directly +echo "--- Step 5: Validating shorewall directory directly ---" +"$TOMSWALL" validate -c /etc/shorewall 2>&1 || true +echo + +# Step 6: Plan against migrated config (shows what tomswall would do) +echo "--- Step 6: Planning changes from migrated config ---" +"$TOMSWALL" plan -c "$TMPDIR/migrated.yaml" 2>&1 || true +echo + +# Step 7: Plan against shorewall directory +echo "--- Step 7: Planning changes from shorewall directory ---" +"$TOMSWALL" plan -c /etc/shorewall 2>&1 || true +echo + +# Step 8: Show the migrated YAML +echo "--- Step 8: Migrated YAML (first 100 lines) ---" +head -100 "$TMPDIR/migrated.yaml" +echo +echo "..." +echo + +echo "=== Test complete ===" +echo "Files saved in $TMPDIR:" +ls -la "$TMPDIR/" +echo +echo "To keep files, copy from: $TMPDIR" +echo "(Directory will be cleaned up on script exit)" +echo +echo "Press Enter to clean up, or Ctrl-C to keep files." +read -r diff --git a/tomswall.example.yaml b/tomswall.example.yaml index 6139639..452f0d1 100644 --- a/tomswall.example.yaml +++ b/tomswall.example.yaml @@ -2,9 +2,12 @@ # Spiritual successor to shorewall — manages nftables directly settings: + # address_family: inet (default), ip (IPv4 only), ip6 (IPv6 only) + address_family: inet ip_forwarding: true log_level: info table_name: tomswall + implicit_continue: false # Named port groups — reusable port+protocol combos referenced in rules portgroups: @@ -28,6 +31,7 @@ portgroups: ports: ["1024-65535"] # Security zones (replaces /etc/shorewall/zones) +# Child zones are listed before parents; nesting via parents field. zones: fw: type: firewall @@ -37,6 +41,10 @@ zones: type: ip dmz: type: ip + # Example nested zone: sam is a sub-zone of net + # sam: + # type: ip + # parents: [net] # Interface-to-zone mappings (replaces /etc/shorewall/interfaces) interfaces: @@ -44,14 +52,18 @@ interfaces: interface: eth0 options: dhcp: true - tcpflags: true nosmurfs: true + routefilter: 1 + logmartians: true - zone: loc interface: eth1 + options: + mss: 1400 - zone: dmz interface: eth2 # Host definitions (replaces /etc/shorewall/hosts) +# Only needed when multiple zones share an interface. hosts: - zone: loc interface: eth1 @@ -59,7 +71,8 @@ hosts: - 192.168.1.0/24 # Default zone-to-zone policies (replaces /etc/shorewall/policy) -# Evaluated in order after specific rules; first match wins +# Evaluated in order; first match wins. +# Intra-zone traffic is implicitly ACCEPTed unless overridden with all+. policy: - source: fw dest: all @@ -80,15 +93,12 @@ policy: log: info # Specific traffic rules (replaces /etc/shorewall/rules) -# Supports zone:address notation, e.g. source: "net:203.0.113.0/24" rules: - # Allow SSH from local network to firewall - action: accept source: loc dest: fw portgroup: ssh - # Allow DNS from local network - action: accept source: loc dest: net @@ -98,26 +108,178 @@ rules: dest: net portgroup: dns_tcp - # Allow web traffic from net to DMZ - action: accept source: net dest: dmz portgroup: web - # Allow ping from local network - action: accept source: loc dest: fw proto: icmp - # Drop all other ICMP from net - action: drop source: net dest: all proto: icmp + # DNAT: forward port 2222 from net to loc host on port 22 + # - action: dnat + # source: net + # dest: loc:192.168.1.3:22 + # proto: tcp + # dport: [2222] + + # Time-restricted rule example + # - action: accept + # source: loc + # dest: net + # portgroup: web + # time: + # weekdays: [Mon, Tue, Wed, Thu, Fri] + # start: "08:00" + # stop: "18:00" + # Source NAT rules (replaces /etc/shorewall/snat) +# First match wins. snat: - action: masquerade source: 192.168.1.0/24 - dest_interface: eth0 + dest: eth0 + + # Load-balanced SNAT across multiple addresses + # - action: snat + # address: 1.1.1.1 + # source: 192.168.1.0/24 + # dest: eth0 + # probability: 0.5 + # - action: snat + # address: 1.1.1.2 + # source: 192.168.1.0/24 + # dest: eth0 + +# One-to-one static NAT (replaces /etc/shorewall/nat) +# Maps an external IP to an internal IP bidirectionally. +# DNAT rules take precedence over static NAT. +# nat: +# - external: 203.0.113.10 +# interface: eth0 +# internal: 192.168.1.10 +# all_interfaces: false +# local: true + +# Network-to-network address mapping (replaces /etc/shorewall/netmap) +# Maps one subnet to another at the IP header level. +# netmap: +# - type: dnat +# net1: 10.0.0.0/24 +# interface: eth0 +# net2: 192.168.1.0/24 +# - type: snat +# net1: 192.168.1.0/24 +# interface: eth0 +# net2: 10.0.0.0/24 + +# Variables (replaces /etc/shorewall/params) +# Simple key-value substitution for reuse across config. +# vars: +# NET_IF: eth0 +# DMZ_NET: 10.0.0.0/24 + +# Connection tracking control (replaces /etc/shorewall/conntrack) +# Bypass conntrack for high-volume traffic or assign CT helpers. +# conntrack: +# - action: notrack +# source: net +# dest: fw +# proto: udp +# dport: [53] +# comment: "Skip conntrack for DNS" +# - action: helper +# source: loc +# dest: net +# proto: tcp +# dport: [21] +# helper: ftp +# comment: "FTP conntrack helper" + +# Blacklist/whitelist rules (replaces /etc/shorewall/blrules) +# Processed before normal rules. ACCEPT/WHITELIST exempt from remaining blrules. +# blrules: +# - action: drop +# source: net:192.88.99.1 +# dest: all +# comment: "Block known bad host" +# - action: whitelist +# source: net:70.90.191.120/29 +# dest: all +# comment: "Trusted range" + +# VPN tunnels (replaces /etc/shorewall/tunnels) +# Allows encapsulated traffic to pass; actual tunnel traffic uses normal rules. +# tunnels: +# - type: ipsec +# zone: net +# gateways: [4.33.99.124] +# - type: openvpn:udp +# zone: net +# gateways: [0.0.0.0/0] +# gateway_zones: [vpn] +# port: 1194 + +# Routing rules (replaces /etc/shorewall/rtrules) +# Directs traffic to specific provider routing tables. +# rtrules: +# - source: eth1 +# provider: ISP1 +# priority: 1000 +# - dest: 10.8.0.0/24 +# provider: main +# priority: 1000 +# comment: "OpenVPN traffic stays in main table" + +# Stopped rules (replaces /etc/shorewall/stoppedrules) +# Traffic permitted when the firewall is stopped. +# stoppedrules: +# - action: accept +# source: eth1 +# dest: $FW +# comment: "Allow local access when stopped" +# - action: accept +# source: $FW +# dest: eth1 +# comment: "Allow firewall to reach LAN when stopped" + +# Multi-ISP / policy routing (replaces /etc/shorewall/providers) +# providers: +# - name: ISP1 +# number: 1 +# mark: 0x10000 +# duplicate: main +# interface: eth0 +# gateway: 206.124.146.254 +# options: +# track: true +# balance: 1 +# copy: [eth2] +# - name: ISP2 +# number: 2 +# mark: 0x20000 +# duplicate: main +# interface: eth3 +# gateway: 130.252.99.254 +# options: +# track: true +# balance: 1 +# copy: [eth2] + +# Proxy NDP (replaces /etc/shorewall6/proxyndp) +# IPv6 equivalent of Proxy ARP — answers NDP queries on behalf of another host. +# proxyndp: +# - address: "2001:db8::100" +# interface: eth1 +# external: eth0 +# persistent: true +# - address: "fd10::1" +# external: eth0 +# haveroute: true