From 2a3eb3b04de76b9c53210569e944400e6b14b7aa Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 28 Jun 2026 23:43:16 +1000 Subject: [PATCH 1/5] Initial scaffold for tomswall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spiritual successor to shorewall — manages nftables directly via google/nftables. Reads a single YAML config covering zones, interfaces, hosts, policy, rules, snat, and named portgroups. Computes differential changes against the running nftables state and applies them atomically. Supports detecting and purging rules added outside of tomswall. --- .gitignore | 2 + Makefile | 25 +++ cmd/tomswall/main.go | 215 ++++++++++++++++++ go.mod | 21 ++ go.sum | 30 +++ internal/config/config.go | 101 +++++++++ internal/config/hosts.go | 28 +++ internal/config/interfaces.go | 38 ++++ internal/config/policy.go | 53 +++++ internal/config/portgroups.go | 58 +++++ internal/config/rules.go | 121 ++++++++++ internal/config/snat.go | 39 ++++ internal/config/zones.go | 47 ++++ internal/nftables/cleanup.go | 95 ++++++++ internal/nftables/compiler.go | 409 ++++++++++++++++++++++++++++++++++ internal/nftables/diff.go | 79 +++++++ internal/nftables/engine.go | 188 ++++++++++++++++ tomswall.example.yaml | 123 ++++++++++ 18 files changed, 1672 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/tomswall/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/hosts.go create mode 100644 internal/config/interfaces.go create mode 100644 internal/config/policy.go create mode 100644 internal/config/portgroups.go create mode 100644 internal/config/rules.go create mode 100644 internal/config/snat.go create mode 100644 internal/config/zones.go create mode 100644 internal/nftables/cleanup.go create mode 100644 internal/nftables/compiler.go create mode 100644 internal/nftables/diff.go create mode 100644 internal/nftables/engine.go create mode 100644 tomswall.example.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20a75fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tomswall +*.test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..af6af11 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +BINARY := tomswall +MODULE := git.unkin.net/unkin/tomswall +PREFIX := /usr/local +CONFDIR := /etc/tomswall + +.PHONY: build install clean check test + +build: + go build -o $(BINARY) ./cmd/tomswall + +install: build + install -Dm755 $(BINARY) $(DESTDIR)$(PREFIX)/sbin/$(BINARY) + install -Dm644 tomswall.example.yaml $(DESTDIR)$(CONFDIR)/tomswall.example.yaml + @if [ ! -f $(DESTDIR)$(CONFDIR)/tomswall.yaml ]; then \ + install -Dm644 tomswall.example.yaml $(DESTDIR)$(CONFDIR)/tomswall.yaml; \ + fi + +clean: + rm -f $(BINARY) + +check: + go vet ./... + +test: + go test ./... diff --git a/cmd/tomswall/main.go b/cmd/tomswall/main.go new file mode 100644 index 0000000..209397d --- /dev/null +++ b/cmd/tomswall/main.go @@ -0,0 +1,215 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "git.unkin.net/unkin/tomswall/internal/config" + "git.unkin.net/unkin/tomswall/internal/nftables" +) + +var configPath string + +func main() { + root := &cobra.Command{ + Use: "tomswall", + Short: "nftables firewall manager — spiritual successor to shorewall", + } + + root.PersistentFlags().StringVarP(&configPath, "config", "c", "/etc/tomswall/tomswall.yaml", "path to configuration file") + + root.AddCommand(applyCmd(), checkCmd(), statusCmd(), purgeCmd(), flushCmd()) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func loadConfig() (*config.Config, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("validation: %w", err) + } + return cfg, nil +} + +func applyCmd() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply configuration to nftables (differential)", + 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(changes.Summary()) + + if dryRun { + return nil + } + + if err := engine.Apply(changes); err != nil { + return fmt.Errorf("applying changes: %w", err) + } + fmt.Println("Changes applied successfully.") + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned changes without applying") + return cmd +} + +func checkCmd() *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Validate configuration without applying", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := loadConfig() + if err != nil { + return err + } + fmt.Println("Configuration is valid.") + return nil + }, + } +} + +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show current firewall state and pending changes", + 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) + } + + foreign, err := engine.FindForeignRules() + if err != nil { + return fmt.Errorf("scanning foreign rules: %w", err) + } + + if changes.Empty() && len(foreign) == 0 { + fmt.Println("Firewall is up to date. No foreign rules detected.") + return nil + } + + if !changes.Empty() { + fmt.Println("Pending changes:") + fmt.Println(changes.Summary()) + } + + if len(foreign) > 0 { + fmt.Printf("\nForeign rules detected (%d):\n", len(foreign)) + for _, r := range foreign { + fmt.Printf(" - %s\n", r) + } + fmt.Println("\nUse 'tomswall purge' to remove foreign rules.") + } + + return nil + }, + } +} + +func purgeCmd() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "purge", + Short: "Remove rules not managed by tomswall", + 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) + } + + foreign, err := engine.FindForeignRules() + if err != nil { + return fmt.Errorf("scanning: %w", err) + } + + if len(foreign) == 0 { + fmt.Println("No foreign rules found.") + return nil + } + + fmt.Printf("Found %d foreign rule(s) to remove:\n", len(foreign)) + for _, r := range foreign { + fmt.Printf(" - %s\n", r) + } + + if dryRun { + return nil + } + + if err := engine.PurgeForeignRules(foreign); err != nil { + return fmt.Errorf("purging: %w", err) + } + fmt.Println("Foreign rules removed.") + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show foreign rules without removing") + return cmd +} + +func flushCmd() *cobra.Command { + return &cobra.Command{ + Use: "flush", + Short: "Remove all tomswall-managed rules and tables", + 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) + } + + if err := engine.Flush(); err != nil { + return fmt.Errorf("flushing: %w", err) + } + fmt.Println("All tomswall rules flushed.") + return nil + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d8664e --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module git.unkin.net/unkin/tomswall + +go 1.23 + +require ( + github.com/google/nftables v0.2.0 + github.com/spf13/cobra v1.8.1 + golang.org/x/sys v0.18.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/google/go-cmp v0.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/native v1.1.0 // indirect + github.com/mdlayher/netlink v1.7.2 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sync v0.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cc8855b --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/nftables v0.2.0 h1:PbJwaBmbVLzpeldoeUKGkE2RjstrjPKMl6oLrfEJ6/8= +github.com/google/nftables v0.2.0/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc h1:R83G5ikgLMxrBvLh22JhdfI8K6YXEPHx5P03Uu3DRs4= +github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9cfa93b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,101 @@ +package config + +import ( + "fmt" + "os" + + "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"` +} + +type Settings struct { + IPForwarding bool `yaml:"ip_forwarding"` + LogLevel string `yaml:"log_level"` + TableName string `yaml:"table_name"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config %s: %w", path, err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + cfg.applyDefaults() + return &cfg, nil +} + +func (c *Config) applyDefaults() { + if c.Settings.TableName == "" { + c.Settings.TableName = "tomswall" + } + if c.Settings.LogLevel == "" { + c.Settings.LogLevel = "info" + } +} + +func (c *Config) Validate() error { + if err := c.validateZones(); err != nil { + return fmt.Errorf("zones: %w", err) + } + if err := c.validateInterfaces(); err != nil { + return fmt.Errorf("interfaces: %w", err) + } + if err := c.validateHosts(); err != nil { + return fmt.Errorf("hosts: %w", err) + } + if err := c.validatePortGroups(); err != nil { + return fmt.Errorf("portgroups: %w", err) + } + if err := c.validatePolicy(); err != nil { + return fmt.Errorf("policy: %w", err) + } + if err := c.validateRules(); err != nil { + return fmt.Errorf("rules: %w", err) + } + if err := c.validateSNAT(); err != nil { + return fmt.Errorf("snat: %w", err) + } + return nil +} + +func (c *Config) FirewallZone() string { + for name, z := range c.Zones { + if z.Type == ZoneFirewall { + return name + } + } + return "" +} + +func (c *Config) ZoneInterfaces(zone string) []string { + var ifaces []string + for _, iface := range c.Interfaces { + if iface.Zone == zone { + ifaces = append(ifaces, iface.Interface) + } + } + return ifaces +} + +func (c *Config) ResolvePortGroup(name string) (*PortGroup, bool) { + pg, ok := c.PortGroups[name] + if !ok { + return nil, false + } + return &pg, true +} diff --git a/internal/config/hosts.go b/internal/config/hosts.go new file mode 100644 index 0000000..7863c3b --- /dev/null +++ b/internal/config/hosts.go @@ -0,0 +1,28 @@ +package config + +import "fmt" + +type Host struct { + Zone string `yaml:"zone"` + Interface string `yaml:"interface"` + Addresses []string `yaml:"addresses"` + Options []string `yaml:"options,omitempty"` +} + +func (c *Config) validateHosts() error { + for i, h := range c.Hosts { + if h.Zone == "" { + return fmt.Errorf("host[%d]: zone required", 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) + } + } + return nil +} diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go new file mode 100644 index 0000000..4789b58 --- /dev/null +++ b/internal/config/interfaces.go @@ -0,0 +1,38 @@ +package config + +import "fmt" + +type Interface struct { + Zone string `yaml:"zone"` + Interface string `yaml:"interface"` + Options InterfaceOptions `yaml:"options,omitempty"` +} + +type InterfaceOptions struct { + DHCP bool `yaml:"dhcp,omitempty"` + TCPFlags bool `yaml:"tcpflags,omitempty"` + NoSmurfs bool `yaml:"nosmurfs,omitempty"` + RouteBack bool `yaml:"routeback,omitempty"` + Bridge bool `yaml:"bridge,omitempty"` + Optional bool `yaml:"optional,omitempty"` +} + +func (c *Config) validateInterfaces() error { + seen := make(map[string]bool) + 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 _, ok := c.Zones[iface.Zone]; !ok { + return fmt.Errorf("interface[%d] %q: zone %q not defined", i, iface.Interface, iface.Zone) + } + if seen[iface.Interface] { + return fmt.Errorf("interface[%d]: duplicate interface %q", i, iface.Interface) + } + seen[iface.Interface] = true + } + return nil +} diff --git a/internal/config/policy.go b/internal/config/policy.go new file mode 100644 index 0000000..2aa05ec --- /dev/null +++ b/internal/config/policy.go @@ -0,0 +1,53 @@ +package config + +import "fmt" + +type PolicyAction string + +const ( + PolicyAccept PolicyAction = "accept" + PolicyDrop PolicyAction = "drop" + PolicyReject PolicyAction = "reject" + PolicyContinue PolicyAction = "continue" + PolicyNone PolicyAction = "none" +) + +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"` +} + +func (c *Config) validatePolicy() error { + if len(c.Policy) == 0 { + return fmt.Errorf("no policies defined") + } + + for i, p := range c.Policy { + if p.Source == "" { + return fmt.Errorf("policy[%d]: source required", i) + } + 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: + default: + return fmt.Errorf("policy[%d]: unknown action %q", i, p.Action) + } + } + return nil +} diff --git a/internal/config/portgroups.go b/internal/config/portgroups.go new file mode 100644 index 0000000..61d32a2 --- /dev/null +++ b/internal/config/portgroups.go @@ -0,0 +1,58 @@ +package config + +import ( + "fmt" + "strconv" + "strings" +) + +type PortGroup struct { + Proto string `yaml:"proto"` + Ports PortSpec `yaml:"ports"` +} + +// ParsedPorts returns individual port numbers and ranges as (start, end) pairs. +func (pg *PortGroup) ParsedPorts() (singles []uint16, ranges [][2]uint16, err error) { + for _, p := range pg.Ports { + if strings.Contains(p, "-") { + parts := strings.SplitN(p, "-", 2) + start, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return nil, nil, fmt.Errorf("invalid port range start %q: %w", parts[0], err) + } + end, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil { + return nil, nil, fmt.Errorf("invalid port range end %q: %w", parts[1], err) + } + if start > end { + return nil, nil, fmt.Errorf("port range %d-%d: start > end", start, end) + } + ranges = append(ranges, [2]uint16{uint16(start), uint16(end)}) + } else { + port, err := strconv.ParseUint(p, 10, 16) + if err != nil { + return nil, nil, fmt.Errorf("invalid port %q: %w", p, err) + } + singles = append(singles, uint16(port)) + } + } + return singles, ranges, nil +} + +func (c *Config) validatePortGroups() error { + for name, pg := range c.PortGroups { + if pg.Proto == "" { + return fmt.Errorf("portgroup %q: proto required", name) + } + if pg.Proto != "tcp" && pg.Proto != "udp" { + return fmt.Errorf("portgroup %q: proto must be tcp or udp, got %q", name, pg.Proto) + } + if len(pg.Ports) == 0 { + return fmt.Errorf("portgroup %q: at least one port required", name) + } + if _, _, err := pg.ParsedPorts(); err != nil { + return fmt.Errorf("portgroup %q: %w", name, err) + } + } + return nil +} diff --git a/internal/config/rules.go b/internal/config/rules.go new file mode 100644 index 0000000..c2ba098 --- /dev/null +++ b/internal/config/rules.go @@ -0,0 +1,121 @@ +package config + +import "fmt" + +type RuleAction string + +const ( + RuleAccept RuleAction = "accept" + RuleDrop RuleAction = "drop" + RuleReject RuleAction = "reject" + RuleDNAT RuleAction = "dnat" + RuleRedirect RuleAction = "redirect" + RuleLog RuleAction = "log" +) + +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"` +} + +// PortSpec supports single ports, ranges, and lists. +// Examples: [80], [443], [80, 443], ["1024-65535"], [53, "80-90"] +type PortSpec []string + +func (ps *PortSpec) UnmarshalYAML(unmarshal func(interface{}) error) error { + var multi []interface{} + if err := unmarshal(&multi); err == nil { + for _, v := range multi { + switch val := v.(type) { + case int: + *ps = append(*ps, fmt.Sprintf("%d", val)) + case float64: + *ps = append(*ps, fmt.Sprintf("%d", int(val))) + case string: + *ps = append(*ps, val) + default: + return fmt.Errorf("unsupported port value type %T", v) + } + } + return nil + } + + var single string + if err := unmarshal(&single); err == nil { + *ps = PortSpec{single} + return nil + } + + var num int + if err := unmarshal(&num); err == nil { + *ps = PortSpec{fmt.Sprintf("%d", num)} + return nil + } + + return fmt.Errorf("invalid port spec") +} + +func (c *Config) validateRules() error { + for i, r := range c.Rules { + switch r.Action { + case RuleAccept, RuleDrop, RuleReject, RuleDNAT, RuleRedirect, RuleLog: + default: + return fmt.Errorf("rule[%d]: unknown action %q", i, r.Action) + } + + if r.Source == "" { + return fmt.Errorf("rule[%d]: source required", i) + } + if r.Dest == "" { + 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) + } + } + + 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.PortGroup != "" { + if _, ok := c.PortGroups[r.PortGroup]; !ok { + return fmt.Errorf("rule[%d]: portgroup %q not defined", i, r.PortGroup) + } + if r.Proto != "" || len(r.DPort) > 0 { + return fmt.Errorf("rule[%d]: portgroup is mutually exclusive with proto/dport", i) + } + } + + if r.Action == RuleDNAT && r.DNATDest == "" { + return fmt.Errorf("rule[%d]: dnat_dest required for DNAT action", i) + } + } + return nil +} + +// zoneFromSpec extracts the zone name from a zone spec like "net" or "net:192.168.1.0/24". +func zoneFromSpec(spec string) string { + for i, c := range spec { + if c == ':' { + return spec[:i] + } + } + return spec +} diff --git a/internal/config/snat.go b/internal/config/snat.go new file mode 100644 index 0000000..64e68bf --- /dev/null +++ b/internal/config/snat.go @@ -0,0 +1,39 @@ +package config + +import "fmt" + +type SNATAction string + +const ( + SNATMasquerade SNATAction = "masquerade" + SNATAddress SNATAction = "snat" +) + +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"` +} + +func (c *Config) validateSNAT() error { + for i, s := range c.SNAT { + switch s.Action { + case SNATMasquerade, SNATAddress: + default: + return fmt.Errorf("snat[%d]: unknown action %q", i, s.Action) + } + + if s.Action == SNATAddress && s.Address == "" { + return fmt.Errorf("snat[%d]: address required for snat action", i) + } + + if s.DestInterface == "" { + return fmt.Errorf("snat[%d]: dest_interface required", i) + } + } + return nil +} diff --git a/internal/config/zones.go b/internal/config/zones.go new file mode 100644 index 0000000..65131f3 --- /dev/null +++ b/internal/config/zones.go @@ -0,0 +1,47 @@ +package config + +import "fmt" + +type ZoneType string + +const ( + ZoneIP ZoneType = "ip" + ZoneIPSec ZoneType = "ipsec" + ZoneFirewall ZoneType = "firewall" + ZoneLoopback ZoneType = "loopback" +) + +type Zone struct { + Type ZoneType `yaml:"type"` + Parent string `yaml:"parent,omitempty"` + Options []string `yaml:"options,omitempty"` +} + +func (c *Config) validateZones() error { + if len(c.Zones) == 0 { + return fmt.Errorf("no zones defined") + } + + firewallCount := 0 + for name, z := range c.Zones { + switch z.Type { + case ZoneIP, ZoneIPSec, ZoneFirewall, ZoneLoopback: + default: + return fmt.Errorf("zone %q: unknown type %q", name, z.Type) + } + if z.Type == ZoneFirewall { + firewallCount++ + } + if z.Parent != "" { + if _, ok := c.Zones[z.Parent]; !ok { + return fmt.Errorf("zone %q: parent zone %q not defined", name, z.Parent) + } + } + } + + if firewallCount != 1 { + return fmt.Errorf("exactly one firewall zone required, found %d", firewallCount) + } + + return nil +} diff --git a/internal/nftables/cleanup.go b/internal/nftables/cleanup.go new file mode 100644 index 0000000..69c31fa --- /dev/null +++ b/internal/nftables/cleanup.go @@ -0,0 +1,95 @@ +package nftables + +import ( + "fmt" + + "github.com/google/nftables" +) + +type ForeignRule struct { + Table string + Chain string + Handle uint64 + Family nftables.TableFamily + + table *nftables.Table + chain *nftables.Chain +} + +func (f ForeignRule) String() string { + return fmt.Sprintf("table=%s chain=%s handle=%d", f.Table, f.Chain, f.Handle) +} + +func (e *Engine) FindForeignRules() ([]ForeignRule, error) { + tables, err := e.conn.ListTables() + if err != nil { + return nil, fmt.Errorf("listing tables: %w", err) + } + + var ourTable *nftables.Table + for _, t := range tables { + if t.Name == e.cfg.Settings.TableName && t.Family == nftables.TableFamilyINet { + ourTable = t + break + } + } + if ourTable == nil { + return nil, nil + } + + compiler := NewCompiler(e.cfg) + desired, err := compiler.Compile() + if err != nil { + return nil, fmt.Errorf("compiling config: %w", err) + } + + desiredTags := make(map[string]bool) + for _, rules := range desired.Rules { + for _, r := range rules { + desiredTags[r.Tag] = true + } + } + + var foreign []ForeignRule + + chains, err := e.conn.ListChainsOfTableFamily(nftables.TableFamilyINet) + if err != nil { + return nil, fmt.Errorf("listing chains: %w", err) + } + + for _, chain := range chains { + if chain.Table.Name != e.cfg.Settings.TableName { + continue + } + rules, err := e.conn.GetRules(ourTable, chain) + if err != nil { + continue + } + for _, rule := range rules { + tag := string(rule.UserData) + if tag == "" || !desiredTags[tag] { + foreign = append(foreign, ForeignRule{ + Table: ourTable.Name, + Chain: chain.Name, + Handle: rule.Handle, + Family: ourTable.Family, + table: ourTable, + chain: chain, + }) + } + } + } + + return foreign, nil +} + +func (e *Engine) PurgeForeignRules(foreign []ForeignRule) error { + for _, f := range foreign { + e.conn.DelRule(&nftables.Rule{ + Table: f.table, + Chain: f.chain, + Handle: f.Handle, + }) + } + return e.conn.Flush() +} diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go new file mode 100644 index 0000000..75d8745 --- /dev/null +++ b/internal/nftables/compiler.go @@ -0,0 +1,409 @@ +package nftables + +import ( + "encoding/binary" + "fmt" + "net" + "strconv" + "strings" + + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +type Compiler struct { + cfg *config.Config +} + +func NewCompiler(cfg *config.Config) *Compiler { + return &Compiler{cfg: cfg} +} + +func (c *Compiler) Compile() (*FirewallState, error) { + state := &FirewallState{ + Rules: make(map[string][]ManagedRule), + } + + if err := c.compileRules(state); err != nil { + return nil, fmt.Errorf("rules: %w", err) + } + if err := c.compilePolicies(state); err != nil { + return nil, fmt.Errorf("policies: %w", err) + } + if err := c.compileSNAT(state); err != nil { + return nil, fmt.Errorf("snat: %w", err) + } + + return state, nil +} + +func (c *Compiler) compileRules(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + for i, rule := range c.cfg.Rules { + tag := fmt.Sprintf("rule:%d", i) + + proto := rule.Proto + var ports config.PortSpec + if rule.PortGroup != "" { + pg, _ := c.cfg.ResolvePortGroup(rule.PortGroup) + proto = pg.Proto + ports = pg.Ports + } else { + ports = rule.DPort + } + + srcZone, _ := splitZoneSpec(rule.Source) + dstZone, _ := splitZoneSpec(rule.Dest) + + 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, + }) + } + } + } + + return nil +} + +func (c *Compiler) compilePolicies(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + for i, pol := range c.cfg.Policy { + tag := fmt.Sprintf("policy:%d", i) + + srcZones := c.expandZoneRef(pol.Source) + dstZones := c.expandZoneRef(pol.Dest) + + for _, sz := range srcZones { + for _, dz := range dstZones { + if sz == dz { + continue + } + + chain := c.selectChain(sz, dz, fwZone) + srcIfaces := c.resolveZoneInterfaces(sz) + dstIfaces := c.resolveZoneInterfaces(dz) + + for _, si := range srcIfaces { + for _, di := range dstIfaces { + var exprs []expr.Any + + if si != "" { + exprs = append(exprs, matchIface(true, si)...) + } + if di != "" && chain == "forward" { + exprs = append(exprs, matchIface(false, di)...) + } + + exprs = append(exprs, policyVerdict(pol.Action)...) + + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: exprs, + Tag: tag, + }) + } + } + } + } + } + + return nil +} + +func (c *Compiler) compileSNAT(state *FirewallState) error { + for i, snat := range c.cfg.SNAT { + tag := fmt.Sprintf("snat:%d", i) + + var exprs []expr.Any + + exprs = append(exprs, matchIface(false, snat.DestInterface)...) + + if snat.Source != "" { + srcExprs, err := matchSourceCIDR(snat.Source) + if err != nil { + return fmt.Errorf("snat[%d]: %w", i, err) + } + exprs = append(exprs, srcExprs...) + } + + if snat.Proto != "" { + exprs = append(exprs, matchProto(snat.Proto)...) + } + + switch snat.Action { + case config.SNATMasquerade: + exprs = append(exprs, &expr.Masq{}) + case config.SNATAddress: + ip := net.ParseIP(snat.Address) + if ip == nil { + return fmt.Errorf("snat[%d]: invalid address %q", i, snat.Address) + } + ip4 := ip.To4() + if ip4 != nil { + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: ip4}, + &expr.NAT{ + 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, + }, + ) + } + } + + state.Rules["postrouting"] = append(state.Rules["postrouting"], ManagedRule{ + Chain: "postrouting", + Exprs: exprs, + Tag: tag, + }) + } + + return nil +} + +func (c *Compiler) selectChain(srcZone, dstZone, fwZone string) string { + if dstZone == fwZone { + return "input" + } + if srcZone == fwZone { + return "output" + } + return "forward" +} + +func (c *Compiler) resolveZoneInterfaces(zone string) []string { + if zone == "all" || zone == "" { + return []string{""} + } + ifaces := c.cfg.ZoneInterfaces(zone) + if len(ifaces) == 0 { + return []string{""} + } + return ifaces +} + +func (c *Compiler) expandZoneRef(ref string) []string { + if ref == "all" { + var zones []string + for name := range c.cfg.Zones { + zones = append(zones, name) + } + return zones + } + return []string{ref} +} + +func (c *Compiler) buildRuleExprs(srcIface, dstIface, chain, proto string, ports config.PortSpec, action config.RuleAction, srcSpec, dstSpec string) ([]expr.Any, error) { + var exprs []expr.Any + + if srcIface != "" { + exprs = append(exprs, matchIface(true, srcIface)...) + } + if dstIface != "" && chain == "forward" { + exprs = append(exprs, matchIface(false, dstIface)...) + } + + _, srcAddr := splitZoneSpec(srcSpec) + _, dstAddr := splitZoneSpec(dstSpec) + + if srcAddr != "" { + src, err := matchSourceCIDR(srcAddr) + if err != nil { + return nil, err + } + exprs = append(exprs, src...) + } + + if dstAddr != "" { + dst, err := matchDestCIDR(dstAddr) + if err != nil { + return nil, err + } + exprs = append(exprs, dst...) + } + + if proto != "" { + exprs = append(exprs, matchProto(proto)...) + } + + for _, portStr := range ports { + p, err := parsePort(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{}) + } + + return exprs, nil +} + +func matchIface(input bool, name string) []expr.Any { + key := expr.MetaKeyOIFNAME + if input { + key = expr.MetaKeyIIFNAME + } + padded := make([]byte, 16) + copy(padded, name+"\x00") + return []expr.Any{ + &expr.Meta{Key: key, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: padded[:len(name)+1]}, + } +} + +func matchProto(proto string) []expr.Any { + var protoNum byte + switch strings.ToLower(proto) { + case "tcp": + protoNum = unix.IPPROTO_TCP + case "udp": + protoNum = unix.IPPROTO_UDP + case "icmp": + protoNum = unix.IPPROTO_ICMP + default: + n, _ := strconv.Atoi(proto) + protoNum = byte(n) + } + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{protoNum}}, + } +} + +func matchDPort(port uint16) []expr.Any { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, port) + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes}, + } +} + +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) + } + + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: ipNet.Mask, + Xor: []byte{0, 0, 0, 0}, + }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ipNet.IP.To4()}, + }, nil +} + +func matchDestCIDR(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 dest address %q", cidr) + } + ip4 := singleIP.To4() + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ip4}, + }, nil + } + + ip4 := ip.To4() + if ip4 == nil { + return nil, fmt.Errorf("IPv6 dest addresses not yet supported: %s", cidr) + } + + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: ipNet.Mask, + Xor: []byte{0, 0, 0, 0}, + }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ipNet.IP.To4()}, + }, nil +} + +func policyVerdict(action config.PolicyAction) []expr.Any { + switch action { + case config.PolicyAccept: + return []expr.Any{&expr.Verdict{Kind: expr.VerdictAccept}} + case config.PolicyDrop: + return []expr.Any{&expr.Verdict{Kind: expr.VerdictDrop}} + case config.PolicyReject: + return []expr.Any{&expr.Reject{}} + default: + return []expr.Any{&expr.Verdict{Kind: expr.VerdictDrop}} + } +} + +func splitZoneSpec(spec string) (zone, addr string) { + idx := strings.IndexByte(spec, ':') + if idx < 0 { + return spec, "" + } + return spec[:idx], spec[idx+1:] +} + +func parsePort(s string) (uint16, error) { + n, err := strconv.ParseUint(s, 10, 16) + if err != nil { + return 0, fmt.Errorf("invalid port %q: %w", s, err) + } + return uint16(n), nil +} diff --git a/internal/nftables/diff.go b/internal/nftables/diff.go new file mode 100644 index 0000000..2232a3e --- /dev/null +++ b/internal/nftables/diff.go @@ -0,0 +1,79 @@ +package nftables + +import ( + "fmt" + "strings" + + "github.com/google/nftables/expr" +) + +type ManagedRule struct { + Chain string + Handle uint64 + Exprs []expr.Any + Tag string +} + +type FirewallState struct { + Rules map[string][]ManagedRule +} + +type ChangeSet struct { + Add []ManagedRule + Remove []ManagedRule +} + +func (cs *ChangeSet) Empty() bool { + return len(cs.Add) == 0 && len(cs.Remove) == 0 +} + +func (cs *ChangeSet) Summary() string { + var b strings.Builder + if len(cs.Add) > 0 { + fmt.Fprintf(&b, " + %d rule(s) to add\n", len(cs.Add)) + for _, r := range cs.Add { + fmt.Fprintf(&b, " + [%s] %s\n", r.Chain, r.Tag) + } + } + if len(cs.Remove) > 0 { + fmt.Fprintf(&b, " - %d rule(s) to remove\n", len(cs.Remove)) + for _, r := range cs.Remove { + fmt.Fprintf(&b, " - [%s] %s (handle %d)\n", r.Chain, r.Tag, r.Handle) + } + } + return b.String() +} + +func computeDiff(current, desired *FirewallState) *ChangeSet { + cs := &ChangeSet{} + + currentByTag := make(map[string][]ManagedRule) + for _, rules := range current.Rules { + for _, r := range rules { + if r.Tag != "" { + currentByTag[r.Tag] = append(currentByTag[r.Tag], r) + } + } + } + + desiredByTag := make(map[string][]ManagedRule) + for _, rules := range desired.Rules { + for _, r := range rules { + desiredByTag[r.Tag] = append(desiredByTag[r.Tag], r) + } + } + + for tag, desiredRules := range desiredByTag { + if _, exists := currentByTag[tag]; !exists { + cs.Add = append(cs.Add, desiredRules...) + } + } + + for tag, currentRules := range currentByTag { + if _, exists := desiredByTag[tag]; !exists { + cs.Remove = append(cs.Remove, currentRules...) + } + } + + return cs +} diff --git a/internal/nftables/engine.go b/internal/nftables/engine.go new file mode 100644 index 0000000..0618a6f --- /dev/null +++ b/internal/nftables/engine.go @@ -0,0 +1,188 @@ +package nftables + +import ( + "fmt" + + "github.com/google/nftables" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +type Engine struct { + cfg *config.Config + conn *nftables.Conn +} + +func NewEngine(cfg *config.Config) (*Engine, error) { + conn, err := nftables.New() + if err != nil { + return nil, fmt.Errorf("connecting to nftables: %w", err) + } + return &Engine{cfg: cfg, conn: conn}, nil +} + +func (e *Engine) ensureTable() *nftables.Table { + return e.conn.AddTable(&nftables.Table{ + Family: nftables.TableFamilyINet, + Name: e.cfg.Settings.TableName, + }) +} + +func (e *Engine) ensureChains(table *nftables.Table) map[string]*nftables.Chain { + chains := map[string]*nftables.Chain{ + "input": { + Name: "input", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookInput, + Priority: nftables.ChainPriorityFilter, + Policy: policyPtr(nftables.ChainPolicyDrop), + }, + "forward": { + Name: "forward", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityFilter, + Policy: policyPtr(nftables.ChainPolicyDrop), + }, + "output": { + Name: "output", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookOutput, + Priority: nftables.ChainPriorityFilter, + Policy: policyPtr(nftables.ChainPolicyAccept), + }, + "postrouting": { + Name: "postrouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPostrouting, + Priority: nftables.ChainPriorityNATSource, + }, + "prerouting": { + Name: "prerouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + }, + } + + for name, chain := range chains { + chains[name] = e.conn.AddChain(chain) + } + return chains +} + +func (e *Engine) Plan() (*ChangeSet, error) { + compiler := NewCompiler(e.cfg) + + desired, err := compiler.Compile() + if err != nil { + return nil, fmt.Errorf("compiling config: %w", err) + } + + current, err := e.readCurrentState() + if err != nil { + return nil, fmt.Errorf("reading current state: %w", err) + } + + return computeDiff(current, desired), nil +} + +func (e *Engine) Apply(changes *ChangeSet) error { + table := e.ensureTable() + chains := e.ensureChains(table) + + for _, r := range changes.Remove { + e.conn.DelRule(&nftables.Rule{ + Table: table, + Chain: chains[r.Chain], + Handle: r.Handle, + }) + } + + for _, r := range changes.Add { + chain, ok := chains[r.Chain] + if !ok { + return fmt.Errorf("unknown chain %q", r.Chain) + } + e.conn.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: r.Exprs, + UserData: []byte(r.Tag), + }) + } + + return e.conn.Flush() +} + +func (e *Engine) Flush() error { + tables, err := e.conn.ListTables() + if err != nil { + return fmt.Errorf("listing tables: %w", err) + } + + for _, t := range tables { + if t.Name == e.cfg.Settings.TableName { + e.conn.DelTable(t) + return e.conn.Flush() + } + } + return nil +} + +func (e *Engine) readCurrentState() (*FirewallState, error) { + state := &FirewallState{ + Rules: make(map[string][]ManagedRule), + } + + tables, err := e.conn.ListTables() + if err != nil { + return state, nil + } + + var ourTable *nftables.Table + for _, t := range tables { + if t.Name == e.cfg.Settings.TableName && t.Family == nftables.TableFamilyINet { + ourTable = t + break + } + } + + if ourTable == nil { + return state, nil + } + + chains, err := e.conn.ListChainsOfTableFamily(nftables.TableFamilyINet) + if err != nil { + return state, nil + } + + for _, chain := range chains { + if chain.Table.Name != e.cfg.Settings.TableName { + continue + } + rules, err := e.conn.GetRules(ourTable, chain) + if err != nil { + continue + } + for _, rule := range rules { + state.Rules[chain.Name] = append(state.Rules[chain.Name], ManagedRule{ + Chain: chain.Name, + Handle: rule.Handle, + Exprs: rule.Exprs, + Tag: string(rule.UserData), + }) + } + } + + return state, nil +} + +func policyPtr(p nftables.ChainPolicy) *nftables.ChainPolicy { + return &p +} diff --git a/tomswall.example.yaml b/tomswall.example.yaml new file mode 100644 index 0000000..6139639 --- /dev/null +++ b/tomswall.example.yaml @@ -0,0 +1,123 @@ +# tomswall configuration +# Spiritual successor to shorewall — manages nftables directly + +settings: + ip_forwarding: true + log_level: info + table_name: tomswall + +# Named port groups — reusable port+protocol combos referenced in rules +portgroups: + web: + proto: tcp + ports: [80, 443] + dns_udp: + proto: udp + ports: [53] + dns_tcp: + proto: tcp + ports: [53] + ssh: + proto: tcp + ports: [22] + mail: + proto: tcp + ports: [25, 465, 587, 993, 995] + high_ports: + proto: tcp + ports: ["1024-65535"] + +# Security zones (replaces /etc/shorewall/zones) +zones: + fw: + type: firewall + net: + type: ip + loc: + type: ip + dmz: + type: ip + +# Interface-to-zone mappings (replaces /etc/shorewall/interfaces) +interfaces: + - zone: net + interface: eth0 + options: + dhcp: true + tcpflags: true + nosmurfs: true + - zone: loc + interface: eth1 + - zone: dmz + interface: eth2 + +# Host definitions (replaces /etc/shorewall/hosts) +hosts: + - zone: loc + interface: eth1 + addresses: + - 192.168.1.0/24 + +# Default zone-to-zone policies (replaces /etc/shorewall/policy) +# Evaluated in order after specific rules; first match wins +policy: + - source: fw + dest: all + action: accept + - source: loc + dest: net + action: accept + - source: loc + dest: fw + action: accept + - source: net + dest: all + action: drop + log: info + - source: all + dest: all + action: reject + 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 + portgroup: dns_udp + - action: accept + source: loc + 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 + +# Source NAT rules (replaces /etc/shorewall/snat) +snat: + - action: masquerade + source: 192.168.1.0/24 + dest_interface: eth0 From 8d9a76c7510ce95522e383f66671cedf87a239a8 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Wed, 1 Jul 2026 23:56:44 +1000 Subject: [PATCH 2/5] Add comprehensive nftables compiler with shorewall feature parity Rewrites the compiler from ~440 to ~1700 lines covering all major shorewall firewall features: loopback, conntrack fast-path, anti-spoof, DHCP, intra-zone, blacklist/whitelist, conntrack notrack, tunnels (13 types), rules with sections, DNAT/redirect, SNAT/masquerade, static NAT, policies with zone exclusions, MSS clamping, rate limiting, connection limiting, negated addresses, ICMP type matching, TCP RST reject, user/UID matching, mark match/set, NFQUEUE, NONAT, and policy-level rate/conn limiting. Adds full config types for all shorewall subsystems (mangle, accounting, maclist, netmap, providers, tunnels, conntrack, blrules, proxyarp/ndp, routes, tc, secmarks), shorewall migration tooling, expanded CLI commands, expression-level diff engine, and 49 unit tests. --- .gitignore | 2 +- cmd/tomswall/main.go | 240 ++++- internal/config/accounting.go | 67 ++ internal/config/arprules.go | 63 ++ internal/config/blrules.go | 75 ++ internal/config/config.go | 165 ++- internal/config/config_test.go | 1009 ++++++++++++++++++ internal/config/conntrack.go | 86 ++ internal/config/extras_test.go | 1444 ++++++++++++++++++++++++++ internal/config/hosts.go | 41 +- internal/config/interfaces.go | 78 +- internal/config/maclist.go | 51 + internal/config/mangle.go | 115 +++ internal/config/names.go | 55 + internal/config/nat.go | 62 ++ internal/config/nesting.go | 102 ++ internal/config/netmap.go | 83 ++ internal/config/params.go | 32 + internal/config/policy.go | 126 ++- internal/config/providers.go | 117 +++ internal/config/proxyarp.go | 38 + internal/config/proxyndp.go | 37 + internal/config/routes.go | 43 + internal/config/rtrules.go | 62 ++ internal/config/rules.go | 167 ++- internal/config/secmarks.go | 27 + internal/config/snat.go | 78 +- internal/config/stoppedrules.go | 58 ++ internal/config/tc.go | 128 +++ internal/config/tunnels.go | 89 ++ internal/config/zones.go | 57 +- internal/nftables/compiler.go | 1462 ++++++++++++++++++++++++-- internal/nftables/compiler_test.go | 1533 ++++++++++++++++++++++++++++ internal/nftables/diff.go | 32 +- internal/shorewall/convert.go | 1445 ++++++++++++++++++++++++++ internal/shorewall/convert_test.go | 727 +++++++++++++ internal/shorewall/parser.go | 172 ++++ internal/shorewall/parser_test.go | 413 ++++++++ scripts/test-migration.sh | 70 ++ tomswall.example.yaml | 180 +++- 40 files changed, 10626 insertions(+), 205 deletions(-) create mode 100644 internal/config/accounting.go create mode 100644 internal/config/arprules.go create mode 100644 internal/config/blrules.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/conntrack.go create mode 100644 internal/config/extras_test.go create mode 100644 internal/config/maclist.go create mode 100644 internal/config/mangle.go create mode 100644 internal/config/names.go create mode 100644 internal/config/nat.go create mode 100644 internal/config/nesting.go create mode 100644 internal/config/netmap.go create mode 100644 internal/config/params.go create mode 100644 internal/config/providers.go create mode 100644 internal/config/proxyarp.go create mode 100644 internal/config/proxyndp.go create mode 100644 internal/config/routes.go create mode 100644 internal/config/rtrules.go create mode 100644 internal/config/secmarks.go create mode 100644 internal/config/stoppedrules.go create mode 100644 internal/config/tc.go create mode 100644 internal/config/tunnels.go create mode 100644 internal/nftables/compiler_test.go create mode 100644 internal/shorewall/convert.go create mode 100644 internal/shorewall/convert_test.go create mode 100644 internal/shorewall/parser.go create mode 100644 internal/shorewall/parser_test.go create mode 100755 scripts/test-migration.sh 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 From e0f54ef3207e9ca77e44feba943cb6e1d26ab5cd Mon Sep 17 00:00:00 2001 From: benvin Date: Mon, 20 Jul 2026 20:05:49 +1000 Subject: [PATCH 3/5] Add tomswall agent (control-plane pull mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `tomswall agent`: it pulls this device's compiled config from tomswallapi, differentially applies it, and reports the applied generation. It caches the last known-good config and, when the control plane is unreachable, keeps applying that cache — it never fails closed. - internal/agent: rendered-config types, HTTP client (fetch + status report), on-disk cache, on-device DNS resolver for dns sets (honors the device's configured resolver, fail-safe on lookup failure), and the pull-apply-report loop behind a mockable Applier. - Translate the interface-agnostic, address-matched rendered model into native tomswall config using the "all:" any-interface source/dest form, reusing the existing differential engine. Named-set members are inlined as concrete addresses (native nft set references are a tracked follow-up). - cmd/tomswall: wire the `agent` subcommand (flags + TOMSWALL_* env, --once). - Unit tests: translation, cache, and the don't-fail-closed fallback loop. - Add DESIGN.md documenting the control-plane architecture. --- DESIGN.md | 457 +++++++++++++++++++++++++++++ cmd/tomswall/agent.go | 77 +++++ cmd/tomswall/main.go | 1 + internal/agent/agent.go | 114 +++++++ internal/agent/agent_test.go | 205 +++++++++++++ internal/agent/cache.go | 36 +++ internal/agent/client.go | 94 ++++++ internal/agent/rendered.go | 70 +++++ internal/agent/resolver.go | 114 +++++++ internal/agent/translate.go | 166 +++++++++++ internal/config/config.go | 26 +- internal/config/config_test.go | 5 +- internal/config/extras_test.go | 23 +- internal/config/interfaces.go | 12 +- internal/config/rules.go | 14 +- internal/config/tc.go | 12 +- internal/config/tunnels.go | 22 +- internal/nftables/compiler.go | 28 +- internal/nftables/compiler_test.go | 12 +- internal/shorewall/parser_test.go | 8 +- 20 files changed, 1414 insertions(+), 82 deletions(-) create mode 100644 DESIGN.md create mode 100644 cmd/tomswall/agent.go create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agent_test.go create mode 100644 internal/agent/cache.go create mode 100644 internal/agent/client.go create mode 100644 internal/agent/rendered.go create mode 100644 internal/agent/resolver.go create mode 100644 internal/agent/translate.go diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..3253925 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,457 @@ +# tomswall control plane — design + +Status: draft / theory-craft. No implementation yet. + +This document specifies a **fleet control plane** for tomswall: a central, +Terraform-managed API that lets you declare zones, address groups, and firewall +policy **once** and have every device in a routed estate enforce a provably +consistent posture. tomswall itself remains the per-host nftables engine; this +adds an orchestration layer above the fleet. + +--- + +## 1. Motivation + +Today each tomswall host owns a complete, independent `tomswall.yaml` — its own +zones, interfaces, policy, and rules. In an estate of many firewalls and routers +that is unmanageable: a single logical intent ("hosts in zone A may reach zone B +on tcp/22") has to be hand-translated into per-hop rules on every device along +every possible path. + +The control plane inverts this. You declare the **intent once**; the API compiles +it into the concrete rules each device needs and serves each device its rendered +config. A connection that crosses several firewalls — +`src → rt1 → rt2 → rt3 → dest` — is expressed as a single rule. + +Key environment facts this design is built around: + +- Internal routing is **dynamic (FRR)** with **ECMP** — paths are not stable and + cannot be pinned. +- **NAT happens only at the edge**; the internal core is purely routed. +- Devices fall into two classes: **routers** (FRR core) and **firewalls** (zone + boundary enforcement). + +--- + +## 2. Terminology + +| term | meaning | +|---|---| +| **zone** | A fleet-global named network segment (a set of subnets). Replaces per-host zones. | +| **subzone** | A zone nested inside a parent zone; its subnets must be ⊂ the parent's. | +| **address group** | A fleet-global named set of addresses → materialized as an nftables named set (ipset). Population source is `static`, `dns`, or `asn`. | +| **fabric** | A routing domain: a group of routers (and the firewall edges attached to it) that share an FRR routing fabric. | +| **binding** | A per-device mapping of a global zone to that device's local interface(s). The only legitimately host-specific object. | +| **intent / rule** | A fleet-global `source → dest` policy statement, matched on zones and/or address groups. | +| **generation** | A monotonic version stamp on rendered config; devices report the generation they have applied. | + +--- + +## 3. Architecture + +``` + terraform ──▶ tomswallapi ◀── peers with FRR (BGP-LS / BMP) + │ · inventory + topology (devices, zones, fabrics, bindings) + │ · match/address-group model (static/dns/asn ipsets) + │ · compiler: intents → per-device tomswall.yaml + │ · reachability validation from routing state + │ · per-device config + set-delta feed (authenticated) + ▼ + fw-a rt1 rt2 rt3 fw-b ── each runs `tomswall agent`: + pull config → differential apply → report generation + + on-device resolver maintains dns ipsets +``` + +Three moving parts: + +1. **`tomswallapi`** — the control plane. Stores the model (Postgres, per the + house stack), peers with FRR for routing/reachability, compiles intents into + per-device configs, and serves them. +2. **`tomswall agent`** — a new pull mode on the existing binary. Periodically + fetches its rendered config, runs the existing differential `apply`, maintains + dns-backed ipsets via an on-device resolver, and reports its applied generation. +3. **FRR peering** — the API consumes routing state (BGP-LS for topology, BMP / + route monitoring for prefix reachability) so it can validate that a zone's + subnet really originates where its firewall claims, and scope which routers an + intent must touch. + +--- + +## 4. Data model: two layers + +The founding constraint: + +> Every host may have a different zone→interface mapping, but all share the same +> zones, address groups, policies, and rules. + +That splits the model into two layers with a hard wall between them. + +### 4.1 Global layer (defined once, byte-identical everywhere) + +`zones` · `subzones` · `address_groups` · `portgroups` · `policies` · `rules` · +`blrules` · `conntrack` · `secmarks` · `vars` · `fabrics`. + +**None of these ever names an interface.** The rule `zone-a → zone-b tcp/22` is +the same object on every device. + +### 4.2 Device layer (the only per-host freedom) + +`class` (router/firewall) · `fabric` membership · per-device `settings` overrides · +`resolver` config · and the **binding table** (`zone → interface(s)`). + +The rendered per-device `tomswall.yaml` = **global rules projected through that +device's binding table**. Same intent, different local interface resolution: + +``` +GLOBAL (shared): rule zone-a → zone-b tcp/22 accept + zones zone-a=10.1.0.0/24 zone-b=10.4.0.0/24 + +fw-a bindings: zone-a → eth1 ; → bond0 (fabric "core") +fw-b bindings: zone-b → ens5 ; → ens4 (fabric "core") +rt3: no zone bindings ; fabric "core" +``` + +`zone-a` is *attached* on fw-a (resolved via its eth1 binding) but *remote* on +fw-b and rt3 (resolved purely by subnet match on the core side). That asymmetry is +fine because the compiled rule is interface-agnostic (§7). + +--- + +## 5. Device classes and fabrics + +- **firewall** — a zone boundary. Zones "live" on firewall interfaces; a firewall + enforces with default-drop between zones and is where the real policy edge sits. +- **router** — an FRR core member belonging to a **fabric**. Enforces + interface-agnostically (any interface, address-matched) because ECMP means the + path is not pinnable. + +A **fabric** is a routing domain with an `enforce_on_routers` flag: + +- `enforce_on_routers = false` (transparent transit) — routers route freely for + internal ranges and rely on conntrack; only boundary firewalls enforce the + intent. Fewest rules; no in-core containment. +- `enforce_on_routers = true` (defense-in-depth) — every router in the fabric also + carries the intent as an interface-agnostic permit with default-drop transit. + Every hop is a checkpoint; contains a compromised core node. + +Per-fabric choice lets a small trusted core run transparent while a larger or +multi-tenant core runs defense-in-depth. + +--- + +## 6. The match model + +Source and dest use a **shorewall-style** grammar. Each direction is a comma-list +of elements; **within an element a zone gates the selector (AND); across elements +the list is a union (OR)**. + +``` +element := zone # bare zone — the zone-to-zone base case + | zone ":" selector # zone AND selector (selector must be paired) +selector := "+" ipset # named address group (static / dns / asn) + | "&" fqdn-group # (fqdn groups are just dns-typed ipsets; "+" also accepted) + +source = "loc, net:+asn_cloudflare, dmz:+partner_api" + # loc OR (net AND asn_cloudflare) OR (dmz AND partner_api) +``` + +Rules: + +- **Bare zone → legal.** `loc → net`, the zone-to-zone base case. +- **`zone:+selector` → legal.** The selector must always be paired with a zone. +- **Bare selector → rejected at plan time.** No floating `+ipset` / `asn:` without + a zone. + +Why the pairing is structural, not cosmetic: an internet-facing zone like `net` +has no finite subnet — it is "everything else" — so it can never stand as a clean +address match on its own. Pairing supplies the missing halves: **the zone gives +direction/interface, the selector gives concrete addresses.** + +``` +rule: loc → net:+asn_cloudflare tcp/443 +edge fw render: oif= daddr @asn_cloudflare tcp dport 443 accept + # zone `net` → the internet-facing binding; + # asn_cloudflare → the actual prefixes to match +``` + +Internal zones (which have subnets) may stand bare; internet/edge zones +effectively require a selector. + +--- + +## 7. Compilation + +### 7.1 Interface-agnostic, address-matched rules (the ECMP unlock) + +Because FRR picks paths dynamically and load-balances across ECMP, rules must +**not** be compiled to per-hop `iif/oif`. Every device carries the same rule +matched on `saddr ∈ source, daddr ∈ dest, proto, port` in the forward chain, on +**any interface**: + +``` +rule: zone-a (10.1.0.0/24 @ fw-a) → zone-b (10.4.0.0/24 @ fw-b) tcp/22 + +fw-a (firewall): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept +rt1…N (routers): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept +fw-b (firewall): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept +``` + +- **Return traffic needs no rules.** Each device is independently stateful, so + every hop's own `ct state established,related accept` handles the reply. Only the + forward direction is emitted. +- **ECMP and asymmetric routing just work** — flow #1 may go rt1→rt3, flow #2 + rt1→rt2→rt3, and the return may take a third path; every device it touches + already permits the 5-tuple and holds its own conntrack state. + +### 7.2 Over-approximation is safe → no exact path computation + +Because rules are interface-agnostic and stateful, programming a permit on a +router the traffic never crosses just leaves an unused rule. So the compiler never +needs FRR's *actual* chosen path — only **which fabric(s) could carry A↔B**, which +is coarse and static-friendly. FRR peering (§3) is used to *narrow* the router set +(and to validate zone origins), not to pin a path. + +### 7.3 nftables set / map form for scale + +Every zone and address group is a **named nft set** (`flags interval` for CIDRs). +Rules reference sets by name. Where many intents land on the same core routers, +compile into **sets / verdict maps** keyed on `(saddr, daddr, proto, dport)` rather +than long linear chains, so match cost stays sub-linear. `zone:+ipset` compiles to +a conjunction of two set lookups (`ip saddr @z_zone` **and** `ip saddr @g_ipset`); +a multi-element list becomes multiple rule lines sharing a verdict. + +The critical property: **membership is the only thing that churns; rules are +stable.** Adding/removing an address is a set-element op — no rule reload, no chain +rebuild, existing connections preserved. + +--- + +## 8. Address groups (ipsets) + +An address group is always an nftables named set. What differs is only how its +members are populated: + +| type | member source | resolved where | refresh | +|---|---|---|---| +| `static` | explicit CIDRs/IPs | API (constant) | n/a | +| `dns` | FQDNs → A/AAAA | **on-device resolver** | per record TTL | +| `asn` | ASN(s) → prefixes via iplocate | **central API** | configurable, default 24h | + +### 8.1 ASN groups + +An ASN group is defined **once, globally**, and materializes a set named by +convention `asn_` (friendly) or `asn_`. An ISP may own several ASNs; +one group merges them into one set. Rules reference it like any ipset — +`net:+asn_cloudflare` — there is **no** special `asn:` selector token. + +```hcl +resource "tomswall_address_group" "asn_cloudflare" { + type = "asn" + asns = ["13335", "209242"] + refresh = "24h" # default; configurable per group / globally +} # → materializes nft set asn_cloudflare +``` + +Expansion is **centralized**: the API holds a single iplocate key (in Vault), +calls the ASN data-type endpoint, caches prefixes per ASN, refreshes on the TTL, +and ships prefix deltas as set-element ops. Devices never call iplocate. ASN +membership is therefore **fleet-uniform**. + +### 8.2 DNS groups and the per-host resolver + +DNS groups resolve **on-device**, so each site honors its own split-horizon / +geo-DNS view. The resolver used follows a precedence chain: + +``` +per-device resolver override → fleet default_resolver → system /etc/resolv.conf +``` + +```hcl +resource "tomswall_device" "fw_a" { resolver = ["10.1.0.53", "10.1.0.54"] } +# global: default_resolver = ["10.0.0.53"] (or "system") +``` + +A device in site A resolves `vpn.corp` against site A's resolver and populates +*its own* set from that view; site B may legitimately differ. DNS membership is +**not** guaranteed fleet-uniform — only the rules and set names are. Record TTL is +used as the nft **element timeout**, so stale entries self-evict and the resolver +refreshes before expiry. + +### 8.3 Set lifecycle notes + +- v4 and v6 members are held in parallel family sets (or inet-family sets). +- Element deltas (`nft add/delete element`) are a separate, lighter feed than rule + rollout; they never bump the rule generation. + +--- + +## 9. NAT / masquerade / netmap / policy — full parity + +The control plane is a **superset** of the tomswall config model, never a lossy +subset: the rendered per-device output *is* a full `tomswall.yaml`, so anything +tomswall can express is reachable. **Every section is a typed, first-class +resource — there is no raw-YAML escape hatch.** Sections are handled in one of +three tiers: + +- **Global-compiled** — defined once, projected identically to every relevant + device: `policy` (default posture), `rules`, `portgroups`, `zones`/subzones, + `blrules`, `conntrack`, `secmarks`, `vars`. +- **Global-intent, device-resolved** — defined once against zones; each device + resolves to its own interfaces via its binding table: `snat`/**masquerade**, + `netmap`, `nat` (1:1). Example: `masquerade source=loc egress=net` lands only on + devices that bind **both** `loc` and `net` (i.e. edge firewalls); interior + routers have no `net` binding and skip it automatically. +- **Per-device** — declared against a device (or a selector): `host`, `provider`, + `rtrules`, `route`, `tunnel`, `stopped_rule`, `proxy_arp`/`ndp`, `arp_rule`, + `maclist`, `accounting`, `mangle`, `tc_*`. + +--- + +## 10. Terraform resource catalog + +### Fleet / topology +- `tomswall_device` — name, `class` (router/firewall), `fabric`, per-device + `settings` overrides, `resolver` +- `tomswall_fabric` — routing domain, `enforce_on_routers` +- `tomswall_binding` — zone→interface map (per device+zone) +- `tomswall_settings` — global defaults (address_family, log_level, ip_forwarding, + table_name) + +### Global-compiled +- `tomswall_zone` — subnets, `parent` (subzones) +- `tomswall_address_group` — ipset; `type` = static/dns/asn +- `tomswall_portgroup` +- `tomswall_policy` — default zone→zone posture, `priority` +- `tomswall_rule` — the `zone:+ipset` / `&fqdn` intents +- `tomswall_blrule` +- `tomswall_conntrack` +- `tomswall_secmark` +- `tomswall_var` + +### Global-intent, device-resolved +- `tomswall_snat` — masquerade/SNAT (auto-scopes to devices binding both zones) +- `tomswall_netmap` — anchored subnet↔subnet +- `tomswall_nat` — 1:1 static, bound to the device holding the public IP + +### Per-device (`device` reference or a `class`/`fabric`/`all` selector) +- `tomswall_host` +- `tomswall_provider` +- `tomswall_routing_rule` (rtrules) +- `tomswall_route` +- `tomswall_tunnel` +- `tomswall_stopped_rule` +- `tomswall_proxy_arp` / `tomswall_proxy_ndp` +- `tomswall_arp_rule` +- `tomswall_maclist` +- `tomswall_accounting` +- `tomswall_mangle` +- `tomswall_tc_device` / `tomswall_tc_class` / `tomswall_tc_filter` / + `tomswall_tc_interface` / `tomswall_tc_priority` + +### Data sources +- `tomswall_device_config` — rendered `tomswall.yaml` preview for a device +- rule fanout preview — which devices an intent will touch (surfaced in `plan`) + +Per-device resources accept **either a single `device` or a selector** so common +objects (a shared static route, a provider) are declared once, preserving the +define-once ethos even in the local tier. + +--- + +## 11. Agent protocol + +`tomswall agent` (or a systemd timer invoking a pull) does: + +1. **Pull** its rendered config by `device_id` from the API (authenticated). +2. Write it to a **local cache file**. +3. Run the existing **differential `apply`** (compute diff vs live nftables, apply + only the delta atomically; never tears the firewall down). +4. Maintain **dns ipsets** via the on-device resolver (add/delete elements on TTL). +5. **Report** the applied `generation` back to the API. + +### 11.1 Do not fail closed + +On API-unreachable: **keep the cache, re-apply it (idempotent no-op), never flush +to deny.** Existing rules ride through control-plane outages untouched; only +*changes* require the API. This is a deliberate availability choice. + +### 11.2 Rollout & convergence + +A rule spanning several devices rolls out as each device pulls independently. +Mid-rollout the connection is blocked at whichever hop has not yet pulled — i.e. +**fail-closed for adds** (safe). Config is **generation-stamped** and devices +report the generation applied, giving a fleet-wide "converged / N behind" view. + +### 11.3 tomswall engine changes required + +- A **pull/agent mode** (`tomswall agent`, or `source: http` in the config loader). +- The **interface-agnostic rule form** (match `saddr`/`daddr` with no `iif/oif`). +- **Resolver config** and on-device dns-set maintenance. +- **Ownership tags** (resource-id + generation as nft comments) so `purge` / + foreign-rule detection never fights control-plane-managed content. + +--- + +## 12. Invariants (validated at plan time) + +1. **Zones/policies/rules/groups are global-only.** A device may *bind* a zone to + an interface; it may never *define* one. No local zone namespace. +2. **Subnet→zone is globally unique.** Every subnet belongs to exactly one zone + fleet-wide; no overlaps. (Ambiguous saddr/daddr matching otherwise.) +3. **Every zone in a rule must be resolvable on every enforcing device** — either + locally bound (attached) or remote-reachable via its fabric. Unresolvable → + rejection, not a silent gap. FRR reachability proves this. +4. **Each zone is anchored at ≥1 firewall interface** — the API knows where every + zone physically enters the fabric, cross-checked against FRR-advertised origins. +5. **A firewall must bind every directly-connected zone.** A connected subnet with + no zone identity is rejected/flagged (checked against FRR-reported prefixes). + Zones the device does not attach are implicitly remote — no verbose "not here" + declarations needed. +6. **Subzones nest by containment** — a subzone's subnet ⊂ its parent's; hierarchy + is global. +7. **Interface names appear only in the binding table.** An interface name leaking + into any global object → rejection. +8. **A selector never appears without a zone.** `zone:+ipset` / `zone:&fqdn` only; + bare `+ipset` / `asn:` → rejection. Bare zones remain legal. +9. **The zone in a pair supplies direction/interface; the selector supplies + addresses** — this is what lets no-subnet zones (`net`, edge) participate. +10. **Every tomswall section is a typed resource.** No raw-YAML passthrough; + nothing bypasses validation. +11. **Config is generation-stamped**; devices report the generation applied. + +--- + +## 13. Fail-safe semantics + +- **Resolution failure keeps last-good membership.** An iplocate outage or DNS + SERVFAIL must never empty a set. (Matches the agent's don't-fail-closed stance.) +- **A genuinely-empty group (NXDOMAIN, ASN with no prefixes) makes its rule inert + and is logged/flagged** — never "match everything." An unresolvable + source/dest disables its rule loudly, never opens it. +- **Adds fail closed, the control plane fails open.** Partial rollout blocks new + flows until every hop converges; a dead API leaves the last-good posture running. + +--- + +## 14. Security / auth + +- Agents authenticate to the API (mTLS or Vault-issued per-device tokens, per the + house pattern). +- The iplocate API key and any resolver credentials live in Vault. +- The API is the single source of truth; state in Postgres. +- Compiled objects carry ownership tags so the on-device engine can distinguish + control-plane content from local/foreign rules. + +--- + +## 15. Open questions / future work + +- **FRR integration depth** — BGP-LS (topology) vs BMP / route monitoring (prefix + reachability) vs a lighter agent-reported FIB. Start with what proves zone + origin and fabric membership; deepen as needed. +- **Set-union match ergonomics** — whether multi-element source/dest compiles to + multiple rule lines or a merged interval set; membership churn is handled at the + member-set level regardless. +- **NAT along non-edge paths** — out of scope by assumption (routed core, edge-only + NAT). Revisit only if internal translation is ever introduced. +- **Multi-tenancy** — whether fabrics/zones need tenant scoping for RBAC. diff --git a/cmd/tomswall/agent.go b/cmd/tomswall/agent.go new file mode 100644 index 0000000..6f91379 --- /dev/null +++ b/cmd/tomswall/agent.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + "git.unkin.net/unkin/tomswall/internal/agent" +) + +func agentCmd() *cobra.Command { + var ( + apiURL string + device string + token string + cachePath string + interval time.Duration + once bool + ) + + cmd := &cobra.Command{ + Use: "agent", + Short: "Pull compiled config from tomswallapi and apply it continuously", + Long: `Agent runs the control-plane pull loop: it fetches this device's compiled +config from tomswallapi, differentially applies it, and reports the applied +generation back. It caches the last known-good config and, if the control plane +is unreachable, keeps applying that cache — it never fails closed. + +The agent token defaults to the TOMSWALL_AGENT_TOKEN environment variable, and +the device name defaults to the system hostname.`, + RunE: func(cmd *cobra.Command, args []string) error { + if token == "" { + token = os.Getenv("TOMSWALL_AGENT_TOKEN") + } + if device == "" { + device, _ = os.Hostname() + } + if apiURL == "" { + return fmt.Errorf("--api-url is required (or set it in the environment)") + } + if device == "" { + return fmt.Errorf("--device is required (could not determine hostname)") + } + if token == "" { + return fmt.Errorf("agent token required: set --token or TOMSWALL_AGENT_TOKEN") + } + + a := &agent.Agent{ + Client: agent.NewClient(apiURL, device, token), + Cache: agent.Cache{Path: cachePath}, + Interval: interval, + Applier: agent.EngineApplier{}, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if once { + return a.RunOnce(ctx) + } + return a.Run(ctx) + }, + } + + cmd.Flags().StringVar(&apiURL, "api-url", os.Getenv("TOMSWALL_API_URL"), "tomswallapi base URL (or TOMSWALL_API_URL)") + cmd.Flags().StringVar(&device, "device", "", "device name (defaults to hostname)") + cmd.Flags().StringVar(&token, "token", "", "agent bearer token (or TOMSWALL_AGENT_TOKEN)") + cmd.Flags().StringVar(&cachePath, "cache", "/var/lib/tomswall/rendered.yaml", "path to the last-known-good config cache") + cmd.Flags().DurationVar(&interval, "interval", time.Minute, "poll interval") + cmd.Flags().BoolVar(&once, "once", false, "run a single apply cycle and exit") + return cmd +} diff --git a/cmd/tomswall/main.go b/cmd/tomswall/main.go index 2cd6ca5..c617769 100644 --- a/cmd/tomswall/main.go +++ b/cmd/tomswall/main.go @@ -39,6 +39,7 @@ Use 'tomswall migrate' to convert a shorewall config to YAML.`, purgeCmd(), flushCmd(), migrateCmd(), + agentCmd(), completionCmd(), ) diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..d72ef0a --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,114 @@ +package agent + +import ( + "context" + "fmt" + "log/slog" + "time" + + "git.unkin.net/unkin/tomswall/internal/config" + "git.unkin.net/unkin/tomswall/internal/nftables" +) + +// Applier applies a translated config to the firewall. Abstracted so the run +// loop is testable without touching the kernel. +type Applier interface { + Apply(ctx context.Context, cfg *config.Config) error +} + +// Agent runs the pull-apply-report loop for one device. +type Agent struct { + Client *Client + Cache Cache + Interval time.Duration + Applier Applier + // Resolver overrides the DNS resolver (tests); nil derives it per-config. + Resolver *Resolver +} + +// Run loops until ctx is cancelled, applying one cycle per Interval (and once +// immediately). A failed cycle is logged and retried on the next tick — the loop +// never exits on transient errors. +func (a *Agent) Run(ctx context.Context) error { + if a.Interval <= 0 { + a.Interval = time.Minute + } + t := time.NewTicker(a.Interval) + defer t.Stop() + for { + if err := a.RunOnce(ctx); err != nil { + slog.Error("agent: apply cycle failed", "err", err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + } + } +} + +// RunOnce performs a single pull-apply-report cycle. On a fetch failure it falls +// back to the on-disk cache and re-applies it — it never fails closed. +func (a *Agent) RunOnce(ctx context.Context) error { + rc, raw, err := a.Client.FetchConfig(ctx) + if err != nil { + slog.Warn("agent: control plane unreachable, using cached config", "err", err) + cached, cerr := a.Cache.Read() + if cerr != nil { + return fmt.Errorf("read cache: %w", cerr) + } + if cached == nil { + return fmt.Errorf("control plane unreachable and no cached config: %w", err) + } + // Re-apply last known-good; do not report a generation we didn't fetch. + return a.applyConfig(ctx, cached, false) + } + + if err := a.Cache.Write(raw); err != nil { + slog.Warn("agent: caching config failed", "err", err) + } + return a.applyConfig(ctx, rc, true) +} + +func (a *Agent) applyConfig(ctx context.Context, rc *RenderedConfig, report bool) error { + resolver := a.Resolver + if resolver == nil { + resolver = NewResolver(rc.Resolver) + } + resolver.ExpandDNSSets(ctx, rc) + + cfg, err := Translate(rc) + if err != nil { + return fmt.Errorf("translate: %w", err) + } + if err := a.Applier.Apply(ctx, cfg); err != nil { + return fmt.Errorf("apply: %w", err) + } + slog.Info("agent: applied config", "generation", rc.Generation, "rules", len(cfg.Rules)) + + if report { + if err := a.Client.ReportStatus(ctx, rc.Generation); err != nil { + slog.Warn("agent: reporting status failed", "err", err) + } + } + return nil +} + +// EngineApplier applies via the real nftables differential engine. +type EngineApplier struct{} + +// Apply computes and applies the differential change set for cfg. +func (EngineApplier) Apply(_ context.Context, cfg *config.Config) error { + 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() { + return nil + } + return engine.Apply(changes) +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..b9e8d76 --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,205 @@ +package agent + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "sync/atomic" + "testing" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +func TestTranslateInterfaceAgnosticRule(t *testing.T) { + rc := &RenderedConfig{ + Generation: 5, + Device: "fw-a", + Enforcing: true, + Settings: RenderedSettings{AddressFamily: "inet", LogLevel: "info", TableName: "tomswall"}, + Bindings: map[string][]string{"zone-a": {"eth1"}}, + Sets: []RenderedSet{ + {Name: "asn_cloudflare", Kind: "asn", Members: []string{"104.16.0.0/13", "1.1.1.0/24"}}, + }, + Rules: []RenderedRule{ + { + Action: "accept", + Source: []RenderedMatch{{Zone: "zone-a", Subnets: []string{"10.1.0.0/24"}}}, + Dest: []RenderedMatch{{Zone: "net", Set: "asn_cloudflare"}}, + Proto: "tcp", + Ports: []string{"443"}, + }, + }, + } + + cfg, err := Translate(rc) + if err != nil { + t.Fatalf("Translate: %v", err) + } + // fw zone + zone-a. + if _, ok := cfg.Zones["fw"]; !ok { + t.Error("missing firewall zone") + } + if _, ok := cfg.Zones["zone-a"]; !ok { + t.Error("missing zone-a") + } + // binding -> interface. + if len(cfg.Interfaces) != 1 || cfg.Interfaces[0].Interface != "eth1" { + t.Errorf("expected one eth1 interface, got %+v", cfg.Interfaces) + } + // 1 source addr x 2 dest addrs (asn set members) = 2 rules. + if len(cfg.Rules) != 2 { + t.Fatalf("expected 2 expanded rules, got %d: %+v", len(cfg.Rules), cfg.Rules) + } + for _, r := range cfg.Rules { + if r.Source != "all:10.1.0.0/24" { + t.Errorf("source not interface-agnostic saddr match: %q", r.Source) + } + if r.Action != config.RuleAccept || r.Proto != "tcp" || len(r.DPort) != 1 || r.DPort[0] != "443" { + t.Errorf("unexpected rule: %+v", r) + } + } + dests := map[string]bool{cfg.Rules[0].Dest: true, cfg.Rules[1].Dest: true} + if !dests["all:104.16.0.0/13"] || !dests["all:1.1.1.0/24"] { + t.Errorf("dest set members not inlined: %v", dests) + } +} + +func TestTranslateBareZone(t *testing.T) { + rc := &RenderedConfig{ + Enforcing: true, + Rules: []RenderedRule{{ + Action: "accept", + Source: []RenderedMatch{{Zone: "zone-a", Subnets: []string{"10.1.0.0/24"}}}, + Dest: []RenderedMatch{{Zone: "zone-b", Subnets: []string{"10.4.0.0/24"}}}, + Proto: "tcp", Ports: []string{"22"}, + }}, + } + cfg, err := Translate(rc) + if err != nil { + t.Fatalf("Translate: %v", err) + } + if len(cfg.Rules) != 1 || cfg.Rules[0].Source != "all:10.1.0.0/24" || cfg.Rules[0].Dest != "all:10.4.0.0/24" { + t.Errorf("unexpected zone-to-zone rule: %+v", cfg.Rules) + } +} + +func TestTranslateRejectsUnknownAction(t *testing.T) { + rc := &RenderedConfig{Enforcing: true, Rules: []RenderedRule{{Action: "bogus"}}} + if _, err := Translate(rc); err == nil { + t.Fatal("expected error for unknown action") + } +} + +// fakeApplier records applied configs. +type fakeApplier struct { + count int32 + lastGen int +} + +func (f *fakeApplier) Apply(_ context.Context, cfg *config.Config) error { + atomic.AddInt32(&f.count, 1) + f.lastGen = len(cfg.Rules) + return nil +} + +const renderedYAML = `generation: 7 +device: fw-a +enforcing: true +settings: + address_family: inet + log_level: info + table_name: tomswall +bindings: + zone-a: [eth1] +rules: + - action: accept + source: + - zone: zone-a + subnets: ["10.1.0.0/24"] + dest: + - zone: zone-b + subnets: ["10.4.0.0/24"] + proto: tcp + ports: ["22"] +` + +func TestRunOnceAppliesAndReports(t *testing.T) { + var reported int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/devices/fw-a/config": + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(renderedYAML)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/devices/fw-a/status": + atomic.StoreInt64(&reported, 1) + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + applier := &fakeApplier{} + a := &Agent{ + Client: NewClient(srv.URL, "fw-a", "tok"), + Cache: Cache{Path: filepath.Join(t.TempDir(), "cache.yaml")}, + Applier: applier, + } + if err := a.RunOnce(context.Background()); err != nil { + t.Fatalf("RunOnce: %v", err) + } + if atomic.LoadInt32(&applier.count) != 1 { + t.Errorf("expected 1 apply, got %d", applier.count) + } + if atomic.LoadInt64(&reported) != 1 { + t.Error("expected status to be reported") + } + // Cache should now be populated. + if cached, err := a.Cache.Read(); err != nil || cached == nil || cached.Generation != 7 { + t.Errorf("cache not written correctly: %+v (err %v)", cached, err) + } +} + +func TestRunOnceFallsBackToCacheNeverFailsClosed(t *testing.T) { + // Server always errors — the control plane is "unreachable". + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + cachePath := filepath.Join(t.TempDir(), "cache.yaml") + if err := (Cache{Path: cachePath}).Write([]byte(renderedYAML)); err != nil { + t.Fatalf("seed cache: %v", err) + } + + applier := &fakeApplier{} + a := &Agent{ + Client: NewClient(srv.URL, "fw-a", "tok"), + Cache: Cache{Path: cachePath}, + Applier: applier, + } + // Fetch fails, but the cached config must still be applied (fail-safe). + if err := a.RunOnce(context.Background()); err != nil { + t.Fatalf("RunOnce should not error when a cache exists: %v", err) + } + if atomic.LoadInt32(&applier.count) != 1 { + t.Errorf("expected cached config to be applied, got %d applies", applier.count) + } +} + +func TestRunOnceNoCacheReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + a := &Agent{ + Client: NewClient(srv.URL, "fw-a", "tok"), + Cache: Cache{Path: filepath.Join(t.TempDir(), "absent.yaml")}, + Applier: &fakeApplier{}, + } + if err := a.RunOnce(context.Background()); err == nil { + t.Fatal("expected error when unreachable and no cache exists") + } +} diff --git a/internal/agent/cache.go b/internal/agent/cache.go new file mode 100644 index 0000000..1fb439b --- /dev/null +++ b/internal/agent/cache.go @@ -0,0 +1,36 @@ +package agent + +import ( + "os" + "path/filepath" +) + +// Cache persists the last known-good rendered config to disk so the agent can +// keep applying it when the control plane is unreachable (never fail closed). +type Cache struct { + Path string +} + +// Write atomically stores the raw config bytes. +func (c Cache) Write(raw []byte) error { + if err := os.MkdirAll(filepath.Dir(c.Path), 0o755); err != nil { + return err + } + tmp := c.Path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, c.Path) +} + +// Read returns the cached config, or (nil, nil) when no cache exists yet. +func (c Cache) Read() (*RenderedConfig, error) { + raw, err := os.ReadFile(c.Path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return ParseRendered(raw) +} diff --git a/internal/agent/client.go b/internal/agent/client.go new file mode 100644 index 0000000..5320e6b --- /dev/null +++ b/internal/agent/client.go @@ -0,0 +1,94 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "gopkg.in/yaml.v3" +) + +// Client talks to the tomswallapi control plane for one device. +type Client struct { + BaseURL string + Device string + Token string + HTTP *http.Client +} + +// NewClient builds a Client with a sane default timeout. +func NewClient(baseURL, device, token string) *Client { + return &Client{ + BaseURL: baseURL, + Device: device, + Token: token, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +// FetchConfig retrieves the device's rendered config. It returns both the parsed +// document and the raw bytes (so callers can cache exactly what was served). +func (c *Client) FetchConfig(ctx context.Context) (*RenderedConfig, []byte, error) { + url := fmt.Sprintf("%s/api/v1/devices/%s/config", c.BaseURL, c.Device) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Accept", "application/yaml") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("fetch config: status %d: %s", resp.StatusCode, bytes.TrimSpace(body)) + } + + cfg, err := ParseRendered(body) + if err != nil { + return nil, nil, err + } + return cfg, body, nil +} + +// ParseRendered decodes a rendered config document (YAML, JSON is a subset). +func ParseRendered(body []byte) (*RenderedConfig, error) { + var cfg RenderedConfig + if err := yaml.Unmarshal(body, &cfg); err != nil { + return nil, fmt.Errorf("parsing rendered config: %w", err) + } + return &cfg, nil +} + +// ReportStatus tells the control plane which generation this device has applied. +func (c *Client) ReportStatus(ctx context.Context, generation int64) error { + url := fmt.Sprintf("%s/api/v1/devices/%s/status", c.BaseURL, c.Device) + payload, _ := json.Marshal(map[string]int64{"generation": generation}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode >= 400 { + return fmt.Errorf("report status: %d", resp.StatusCode) + } + return nil +} diff --git a/internal/agent/rendered.go b/internal/agent/rendered.go new file mode 100644 index 0000000..124b249 --- /dev/null +++ b/internal/agent/rendered.go @@ -0,0 +1,70 @@ +// Package agent implements `tomswall agent`: it pulls a device's compiled config +// from tomswallapi, differentially applies it, and reports the applied generation. +// It never fails closed — if the control plane is unreachable it keeps the last +// known-good config running. +package agent + +// RenderedConfig is the per-device document served by tomswallapi at +// GET /api/v1/devices/{name}/config. It mirrors the control plane's compiler +// output: interface-agnostic, address-matched rules plus named sets. +type RenderedConfig struct { + Generation int64 `yaml:"generation" json:"generation"` + Device string `yaml:"device" json:"device"` + Class string `yaml:"class" json:"class"` + Enforcing bool `yaml:"enforcing" json:"enforcing"` + Settings RenderedSettings `yaml:"settings" json:"settings"` + Resolver []string `yaml:"resolver,omitempty" json:"resolver,omitempty"` + Bindings map[string][]string `yaml:"bindings,omitempty" json:"bindings,omitempty"` // zone -> interfaces + Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"` + Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"` + Policies []RenderedPolicy `yaml:"policies,omitempty" json:"policies,omitempty"` +} + +type RenderedSettings struct { + AddressFamily string `yaml:"address_family" json:"address_family"` + LogLevel string `yaml:"log_level" json:"log_level"` + IPForwarding bool `yaml:"ip_forwarding" json:"ip_forwarding"` + TableName string `yaml:"table_name" json:"table_name"` +} + +// RenderedSet is an address group's nftables set. Members carries the concrete +// elements the control plane knows (static CIDRs, expanded ASN prefixes); FQDNs +// are resolved on-device; ASNs are informational (already expanded into Members). +type RenderedSet struct { + Name string `yaml:"name" json:"name"` + Kind string `yaml:"kind" json:"kind"` // static | dns | asn + Members []string `yaml:"members,omitempty" json:"members,omitempty"` + FQDNs []string `yaml:"fqdns,omitempty" json:"fqdns,omitempty"` + ASNs []string `yaml:"asns,omitempty" json:"asns,omitempty"` + Refresh string `yaml:"refresh,omitempty" json:"refresh,omitempty"` +} + +// RenderedMatch is one OR'd element of a rule direction: a zone's subnets AND, +// optionally, a named set to intersect with. +type RenderedMatch struct { + Zone string `yaml:"zone" json:"zone"` + Subnets []string `yaml:"subnets,omitempty" json:"subnets,omitempty"` + Set string `yaml:"set,omitempty" json:"set,omitempty"` +} + +type RenderedRule struct { + Action string `yaml:"action" json:"action"` + Source []RenderedMatch `yaml:"source" json:"source"` + Dest []RenderedMatch `yaml:"dest" json:"dest"` + Proto string `yaml:"proto,omitempty" json:"proto,omitempty"` + Ports []string `yaml:"ports,omitempty" json:"ports,omitempty"` + Log string `yaml:"log,omitempty" json:"log,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +type RenderedPolicy struct { + Priority int `yaml:"priority" json:"priority"` + Source string `yaml:"source" json:"source"` + Dest string `yaml:"dest" json:"dest"` + Action string `yaml:"action" json:"action"` + Log string `yaml:"log,omitempty" json:"log,omitempty"` +} + +// setMembers returns the concrete address elements for a set: static/asn use +// Members; dns is resolved separately and merged in before translation. +func (s RenderedSet) staticMembers() []string { return s.Members } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go new file mode 100644 index 0000000..2f2b94b --- /dev/null +++ b/internal/agent/resolver.go @@ -0,0 +1,114 @@ +package agent + +import ( + "context" + "fmt" + "log/slog" + "net" + "time" +) + +// Resolver resolves dns-set FQDNs to host CIDRs on-device, honoring the +// device's configured resolver (falling back to the system resolver). +type Resolver struct { + // Servers are resolver addresses (host or host:port); empty uses the system + // resolver. The literal "system" is treated the same as empty. + Servers []string +} + +// NewResolver builds a Resolver for the given server list. +func NewResolver(servers []string) *Resolver { + if len(servers) == 1 && servers[0] == "system" { + servers = nil + } + return &Resolver{Servers: servers} +} + +func (r *Resolver) netResolver() *net.Resolver { + if len(r.Servers) == 0 { + return net.DefaultResolver + } + servers := r.Servers + dialer := &net.Dialer{Timeout: 5 * time.Second} + var idx int + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + // Round-robin across configured servers for resilience. + addr := servers[idx%len(servers)] + idx++ + if _, _, err := net.SplitHostPort(addr); err != nil { + addr = net.JoinHostPort(addr, "53") + } + return dialer.DialContext(ctx, network, addr) + }, + } +} + +// Resolve returns host CIDRs (/32 or /128) for a FQDN's A and AAAA records. +func (r *Resolver) Resolve(ctx context.Context, fqdn string) ([]string, error) { + ips, err := r.netResolver().LookupIP(ctx, "ip", fqdn) + if err != nil { + return nil, err + } + out := make([]string, 0, len(ips)) + for _, ip := range ips { + if ip4 := ip.To4(); ip4 != nil { + out = append(out, ip4.String()+"/32") + } else { + out = append(out, ip.String()+"/128") + } + } + return out, nil +} + +// ExpandDNSSets resolves every dns set's FQDNs and populates its Members in +// place. Resolution failures are logged and leave the prior Members untouched +// (fail-safe): a resolver outage must never empty a set. +func (r *Resolver) ExpandDNSSets(ctx context.Context, cfg *RenderedConfig) { + for i := range cfg.Sets { + set := &cfg.Sets[i] + if set.Kind != "dns" { + continue + } + var members []string + var anyErr bool + for _, fqdn := range set.FQDNs { + cidrs, err := r.Resolve(ctx, fqdn) + if err != nil { + slog.Warn("agent: dns resolution failed, keeping last-good", "set", set.Name, "fqdn", fqdn, "err", err) + anyErr = true + continue + } + members = append(members, cidrs...) + } + // Only replace membership when we resolved something; never empty a set + // on total failure. + if len(members) > 0 { + set.Members = dedup(members) + } else if anyErr { + slog.Warn("agent: dns set kept last-good members", "set", set.Name) + } + } +} + +func dedup(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := in[:0] + for _, s := range in { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +// validateCIDR is a small guard used by translation to skip malformed members. +func validateCIDR(s string) error { + if _, _, err := net.ParseCIDR(s); err != nil { + return fmt.Errorf("invalid CIDR %q: %w", s, err) + } + return nil +} diff --git a/internal/agent/translate.go b/internal/agent/translate.go new file mode 100644 index 0000000..8c5adf8 --- /dev/null +++ b/internal/agent/translate.go @@ -0,0 +1,166 @@ +package agent + +import ( + "fmt" + "sort" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +// Translate converts a control-plane RenderedConfig into a native tomswall +// config.Config that the existing differential engine can apply. +// +// The rendered model is interface-agnostic and address-matched; tomswall +// expresses that with the "all:" source/dest form (zone "all" imposes no +// interface constraint, the CIDR is matched on saddr/daddr). Named sets are +// inlined as their concrete members: a rule element matching N source addresses +// against M dest addresses expands to N*M address-matched rules. This is a +// correct v1; native nftables set references (so membership churns without a +// rule rebuild) are a tracked follow-up. +func Translate(rc *RenderedConfig) (*config.Config, error) { + cfg := &config.Config{ + Settings: config.Settings{ + AddressFamily: config.AddressFamily(orDefault(rc.Settings.AddressFamily, "inet")), + IPForwarding: rc.Settings.IPForwarding, + LogLevel: orDefault(rc.Settings.LogLevel, "info"), + TableName: orDefault(rc.Settings.TableName, "tomswall"), + }, + Zones: map[string]config.Zone{}, + PortGroups: map[string]config.PortGroup{}, + } + + // The firewall zone is required; bound zones map to their local interfaces. + cfg.Zones["fw"] = config.Zone{Type: config.ZoneFirewall} + for zone, ifaces := range rc.Bindings { + cfg.Zones[zone] = config.Zone{Type: config.ZoneIP} + for _, iface := range ifaces { + cfg.Interfaces = append(cfg.Interfaces, config.Interface{Zone: zone, Interface: iface}) + } + } + sort.Slice(cfg.Interfaces, func(i, j int) bool { + return cfg.Interfaces[i].Interface < cfg.Interfaces[j].Interface + }) + + setMembers := indexSets(rc.Sets) + + for i, rr := range rc.Rules { + rules, err := translateRule(rr, setMembers) + if err != nil { + return nil, fmt.Errorf("rule %d: %w", i, err) + } + cfg.Rules = append(cfg.Rules, rules...) + } + + for _, p := range rc.Policies { + cfg.Policy = append(cfg.Policy, config.Policy{ + Source: orDefault(p.Source, "all"), + Dest: orDefault(p.Dest, "all"), + Action: config.PolicyAction(p.Action), + Log: p.Log, + }) + } + + return cfg, nil +} + +// indexSets maps set name -> concrete member CIDRs (invalid members skipped). +func indexSets(sets []RenderedSet) map[string][]string { + m := make(map[string][]string, len(sets)) + for _, s := range sets { + var members []string + for _, cidr := range s.staticMembers() { + if validateCIDR(cidr) == nil { + members = append(members, cidr) + } + } + m[s.Name] = members + } + return m +} + +// addressesFor returns the union of concrete source/dest addresses for a +// direction's OR'd match elements. A match's addresses are its set members when +// a set is referenced, otherwise its zone subnets. +func addressesFor(matches []RenderedMatch, setMembers map[string][]string) []string { + seen := map[string]struct{}{} + var out []string + add := func(cidrs []string) { + for _, c := range cidrs { + if _, ok := seen[c]; ok { + continue + } + if validateCIDR(c) != nil { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + } + for _, m := range matches { + if m.Set != "" { + add(setMembers[m.Set]) + continue + } + add(m.Subnets) + } + return out +} + +// translateRule expands one rendered rule into address-matched tomswall rules. +func translateRule(rr RenderedRule, setMembers map[string][]string) ([]config.Rule, error) { + action, err := translateAction(rr.Action) + if err != nil { + return nil, err + } + srcAddrs := addressesFor(rr.Source, setMembers) + dstAddrs := addressesFor(rr.Dest, setMembers) + // A direction with no concrete addresses matches "any" for that side. + if len(srcAddrs) == 0 { + srcAddrs = []string{""} + } + if len(dstAddrs) == 0 { + dstAddrs = []string{""} + } + + var out []config.Rule + for _, s := range srcAddrs { + for _, d := range dstAddrs { + out = append(out, config.Rule{ + Action: action, + Source: anySpec(s), + Dest: anySpec(d), + Proto: rr.Proto, + DPort: config.PortSpec(rr.Ports), + Log: rr.Log, + Comment: rr.Comment, + }) + } + } + return out, nil +} + +// anySpec renders an interface-agnostic source/dest spec: "all" with an optional +// CIDR constraint. +func anySpec(cidr string) string { + if cidr == "" { + return "all" + } + return "all:" + cidr +} + +func translateAction(a string) (config.RuleAction, error) { + switch config.RuleAction(a) { + case config.RuleAccept, config.RuleDrop, config.RuleReject, + config.RuleLog, config.RuleContinue, config.RuleCount: + return config.RuleAction(a), nil + default: + return "", fmt.Errorf("unsupported action %q", a) + } +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} diff --git a/internal/config/config.go b/internal/config/config.go index 40fe1ab..26f1b7b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,15 +32,15 @@ type Config struct { 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"` + 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 @@ -52,10 +52,10 @@ const ( ) type Settings struct { - AddressFamily AddressFamily `yaml:"address_family,omitempty"` - 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"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1f08bc3..4041c1b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -992,8 +992,8 @@ func TestValidateSNAT(t *testing.T) { wantErr: "persistent requires an address", }, { - name: "empty snat list is valid", - snat: nil, + name: "empty snat list is valid", + snat: nil, }, } @@ -1006,4 +1006,3 @@ func TestValidateSNAT(t *testing.T) { }) } } - diff --git a/internal/config/extras_test.go b/internal/config/extras_test.go index 9121d60..f83941d 100644 --- a/internal/config/extras_test.go +++ b/internal/config/extras_test.go @@ -215,7 +215,7 @@ func TestValidateRoutingRules(t *testing.T) { }, }, { - name: "missing providers", + name: "missing providers", setup: baseConfig, rules: []RoutingRule{ {Source: "10.0.0.0/8", Provider: "isp1", Priority: 1000}, @@ -1174,9 +1174,9 @@ 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"}}, + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "dmz": {Type: ZoneIP, Parents: []string{"net"}}, }, } order, err := cfg.ResolveNesting() @@ -1259,9 +1259,9 @@ func TestIsSubZone(t *testing.T) { }{ {"dmz", "net", true}, {"web", "dmz", true}, - {"web", "net", true}, // transitive - {"net", "dmz", false}, // reverse - {"net", "net", false}, // self + {"web", "net", true}, // transitive + {"net", "dmz", false}, // reverse + {"net", "net", false}, // self {"nosuch", "net", false}, // non-existent } @@ -1305,10 +1305,10 @@ func TestValidateName(t *testing.T) { func TestSubstituteVars(t *testing.T) { tests := []struct { - name string - input string - vars map[string]string - want string + name string + input string + vars map[string]string + want string }{ { name: "braced substitution", @@ -1441,4 +1441,3 @@ func TestValidateSettings(t *testing.T) { }) } } - diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index c4838df..1a65e21 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -13,14 +13,14 @@ type Interface struct { type InterfaceOptions struct { // Rule generation options - DHCP bool `yaml:"dhcp,omitempty"` + DHCP bool `yaml:"dhcp,omitempty"` TCPFlags *bool `yaml:"tcpflags,omitempty"` - NoSmurfs bool `yaml:"nosmurfs,omitempty"` + NoSmurfs bool `yaml:"nosmurfs,omitempty"` RouteBack *bool `yaml:"routeback,omitempty"` - Bridge bool `yaml:"bridge,omitempty"` - DestOnly bool `yaml:"destonly,omitempty"` - Unmanaged bool `yaml:"unmanaged,omitempty"` - Upnp bool `yaml:"upnp,omitempty"` + Bridge bool `yaml:"bridge,omitempty"` + DestOnly bool `yaml:"destonly,omitempty"` + Unmanaged bool `yaml:"unmanaged,omitempty"` + Upnp bool `yaml:"upnp,omitempty"` // Startup behavior Optional bool `yaml:"optional,omitempty"` diff --git a/internal/config/rules.go b/internal/config/rules.go index f3196a5..0a52040 100644 --- a/internal/config/rules.go +++ b/internal/config/rules.go @@ -91,13 +91,13 @@ type Rule struct { } 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"` + 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. diff --git a/internal/config/tc.go b/internal/config/tc.go index 739119e..c9066a3 100644 --- a/internal/config/tc.go +++ b/internal/config/tc.go @@ -5,8 +5,8 @@ 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 + 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"` } @@ -20,10 +20,10 @@ type TCDeviceOptions struct { // 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 + 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"` diff --git a/internal/config/tunnels.go b/internal/config/tunnels.go index 60fc2af..6f73d04 100644 --- a/internal/config/tunnels.go +++ b/internal/config/tunnels.go @@ -5,19 +5,19 @@ 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" + 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" + TunnelTinc TunnelType = "tinc" + Tunnel6to4 TunnelType = "6to4" + TunnelGeneric TunnelType = "generic" ) // Tunnel defines VPN tunnel rules that allow encapsulated traffic to pass diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go index ae6c053..45e6549 100644 --- a/internal/nftables/compiler.go +++ b/internal/nftables/compiler.go @@ -1126,19 +1126,19 @@ func matchTCPFlagsDrop(iface string) []expr.Any { } 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, + "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 { @@ -1589,7 +1589,7 @@ func buildLog(level, prefix string) []expr.Any { } return []expr.Any{ &expr.Log{ - Key: 1 << unix.NFTA_LOG_PREFIX | 1< Date: Mon, 20 Jul 2026 22:12:04 +1000 Subject: [PATCH 4/5] Add PR CI pipelines (build, test, pre-commit) --- .pre-commit-config.yaml | 24 ++++++++++++++++++++++++ .woodpecker/build.yaml | 18 ++++++++++++++++++ .woodpecker/pre-commit.yaml | 18 ++++++++++++++++++ .woodpecker/test.yaml | 19 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/test.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3d17912 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + + - repo: local + hooks: + - id: gofmt + name: gofmt + entry: gofmt -l -d + language: system + types: [go] + pass_filenames: true + - id: go-vet + name: go vet + entry: go vet ./... + language: system + types: [go] + pass_filenames: false diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..f288e50 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: build + image: golang:1.23 + commands: + - go build ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..9aea57f --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,19 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.23 + commands: + - go vet ./... + - go test ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 From 174b2f93b9adcffa5497b6cd55b7602433785396 Mon Sep 17 00:00:00 2001 From: benvin Date: Mon, 20 Jul 2026 22:19:11 +1000 Subject: [PATCH 5/5] Fix end-of-file newline (pre-commit) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a09e942..8c51587 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # tomswall -Spiritual successor to shorewall — nftables firewall manager using google/nftables \ No newline at end of file +Spiritual successor to shorewall — nftables firewall manager using google/nftables