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.
This commit is contained in:
2026-07-01 23:56:44 +10:00
parent 2a3eb3b04d
commit 8d9a76c751
40 changed files with 10626 additions and 205 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
tomswall /tomswall
*.test *.test
+227 -13
View File
@@ -1,13 +1,17 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"git.unkin.net/unkin/tomswall/internal/config" "git.unkin.net/unkin/tomswall/internal/config"
"git.unkin.net/unkin/tomswall/internal/nftables" "git.unkin.net/unkin/tomswall/internal/nftables"
"git.unkin.net/unkin/tomswall/internal/shorewall"
) )
var configPath string var configPath string
@@ -16,11 +20,27 @@ func main() {
root := &cobra.Command{ root := &cobra.Command{
Use: "tomswall", Use: "tomswall",
Short: "nftables firewall manager — spiritual successor to shorewall", 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 { if err := root.Execute(); err != nil {
os.Exit(1) os.Exit(1)
@@ -28,10 +48,24 @@ func main() {
} }
func loadConfig() (*config.Config, error) { func loadConfig() (*config.Config, error) {
cfg, err := config.Load(configPath) info, err := os.Stat(configPath)
if err != nil { 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 { if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validation: %w", err) return nil, fmt.Errorf("validation: %w", err)
} }
@@ -39,10 +73,12 @@ func loadConfig() (*config.Config, error) {
} }
func applyCmd() *cobra.Command { func applyCmd() *cobra.Command {
var dryRun bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "apply", Use: "apply",
Short: "Apply configuration to nftables (differential)", 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 { RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
@@ -66,10 +102,6 @@ func applyCmd() *cobra.Command {
fmt.Println(changes.Summary()) fmt.Println(changes.Summary())
if dryRun {
return nil
}
if err := engine.Apply(changes); err != nil { if err := engine.Apply(changes); err != nil {
return fmt.Errorf("applying changes: %w", err) return fmt.Errorf("applying changes: %w", err)
} }
@@ -77,14 +109,53 @@ func applyCmd() *cobra.Command {
return nil return nil
}, },
} }
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned changes without applying")
return cmd return cmd
} }
func checkCmd() *cobra.Command { func planCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "check", Use: "plan",
Short: "Validate configuration without applying", 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 { RunE: func(cmd *cobra.Command, args []string) error {
_, err := loadConfig() _, err := loadConfig()
if err != nil { 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
}
+67
View File
@@ -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
}
+63
View File
@@ -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
}
+75
View File
@@ -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
}
+152 -13
View File
@@ -1,29 +1,67 @@
package config package config
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Config struct { type Config struct {
Settings Settings `yaml:"settings"` Settings Settings `yaml:"settings"`
PortGroups map[string]PortGroup `yaml:"portgroups"` Vars map[string]string `yaml:"vars,omitempty"`
Zones map[string]Zone `yaml:"zones"` PortGroups map[string]PortGroup `yaml:"portgroups"`
Interfaces []Interface `yaml:"interfaces"` Zones map[string]Zone `yaml:"zones"`
Hosts []Host `yaml:"hosts"` Interfaces []Interface `yaml:"interfaces"`
Policy []Policy `yaml:"policy"` Hosts []Host `yaml:"hosts"`
Rules []Rule `yaml:"rules"` Policy []Policy `yaml:"policy"`
SNAT []SNATRule `yaml:"snat"` 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 { type Settings struct {
IPForwarding bool `yaml:"ip_forwarding"` AddressFamily AddressFamily `yaml:"address_family,omitempty"`
LogLevel string `yaml:"log_level"` IPForwarding bool `yaml:"ip_forwarding"`
TableName string `yaml:"table_name"` 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) { func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -31,14 +69,32 @@ func Load(path string) (*Config, error) {
} }
var cfg Config var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil { ext := strings.ToLower(filepath.Ext(path))
return nil, fmt.Errorf("parsing config: %w", err) 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() cfg.applyDefaults()
return &cfg, nil 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() { func (c *Config) applyDefaults() {
if c.Settings.TableName == "" { if c.Settings.TableName == "" {
c.Settings.TableName = "tomswall" c.Settings.TableName = "tomswall"
@@ -46,9 +102,26 @@ func (c *Config) applyDefaults() {
if c.Settings.LogLevel == "" { if c.Settings.LogLevel == "" {
c.Settings.LogLevel = "info" 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 { func (c *Config) Validate() error {
if err := c.validateSettings(); err != nil {
return fmt.Errorf("settings: %w", err)
}
if err := c.validateZones(); err != nil { if err := c.validateZones(); err != nil {
return fmt.Errorf("zones: %w", err) return fmt.Errorf("zones: %w", err)
} }
@@ -70,6 +143,72 @@ func (c *Config) Validate() error {
if err := c.validateSNAT(); err != nil { if err := c.validateSNAT(); err != nil {
return fmt.Errorf("snat: %w", err) 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 return nil
} }
File diff suppressed because it is too large Load Diff
+86
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+35 -6
View File
@@ -3,25 +3,54 @@ package config
import "fmt" import "fmt"
type Host struct { type Host struct {
Zone string `yaml:"zone"` Zone string `yaml:"zone"`
Interface string `yaml:"interface"` Interface string `yaml:"interface"`
Addresses []string `yaml:"addresses"` Addresses []string `yaml:"addresses"`
Options []string `yaml:"options,omitempty"` 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 { func (c *Config) validateHosts() error {
fwZone := c.FirewallZone()
for i, h := range c.Hosts { for i, h := range c.Hosts {
if h.Zone == "" { if h.Zone == "" {
return fmt.Errorf("host[%d]: zone required", i) 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 { if _, ok := c.Zones[h.Zone]; !ok {
return fmt.Errorf("host[%d]: zone %q not defined", i, h.Zone) return fmt.Errorf("host[%d]: zone %q not defined", i, h.Zone)
} }
if h.Interface == "" { if h.Interface == "" {
return fmt.Errorf("host[%d]: interface required", i) 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 return nil
+69 -9
View File
@@ -1,34 +1,94 @@
package config package config
import "fmt" import (
"fmt"
"strings"
)
type Interface struct { type Interface struct {
Zone string `yaml:"zone"` Zone string `yaml:"zone,omitempty"`
Interface string `yaml:"interface"` Interface string `yaml:"interface"`
Options InterfaceOptions `yaml:"options,omitempty"` Options InterfaceOptions `yaml:"options,omitempty"`
} }
type InterfaceOptions struct { type InterfaceOptions struct {
// Rule generation options
DHCP bool `yaml:"dhcp,omitempty"` DHCP bool `yaml:"dhcp,omitempty"`
TCPFlags bool `yaml:"tcpflags,omitempty"` TCPFlags *bool `yaml:"tcpflags,omitempty"`
NoSmurfs bool `yaml:"nosmurfs,omitempty"` NoSmurfs bool `yaml:"nosmurfs,omitempty"`
RouteBack bool `yaml:"routeback,omitempty"` RouteBack *bool `yaml:"routeback,omitempty"`
Bridge bool `yaml:"bridge,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 { func (c *Config) validateInterfaces() error {
seen := make(map[string]bool) seen := make(map[string]bool)
fwZone := c.FirewallZone()
for i, iface := range c.Interfaces { for i, iface := range c.Interfaces {
if iface.Interface == "" { if iface.Interface == "" {
return fmt.Errorf("interface[%d]: interface name required", i) return fmt.Errorf("interface[%d]: interface name required", i)
} }
if iface.Zone == "" { if strings.Contains(iface.Interface, ":") {
return fmt.Errorf("interface[%d] %q: zone required", i, 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] { if seen[iface.Interface] {
return fmt.Errorf("interface[%d]: duplicate interface %q", i, iface.Interface) return fmt.Errorf("interface[%d]: duplicate interface %q", i, iface.Interface)
} }
+51
View File
@@ -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
}
+115
View File
@@ -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
}
+55
View File
@@ -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
}
+62
View File
@@ -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
}
+102
View File
@@ -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
}
+83
View File
@@ -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
}
+32
View File
@@ -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
}
+108 -18
View File
@@ -1,6 +1,9 @@
package config package config
import "fmt" import (
"fmt"
"strings"
)
type PolicyAction string type PolicyAction string
@@ -10,15 +13,35 @@ const (
PolicyReject PolicyAction = "reject" PolicyReject PolicyAction = "reject"
PolicyContinue PolicyAction = "continue" PolicyContinue PolicyAction = "continue"
PolicyNone PolicyAction = "none" 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 { type Policy struct {
Source string `yaml:"source"` // Source zone(s). Supports: zone name, "all", "all+" (overrides intra-zone ACCEPT),
Dest string `yaml:"dest"` // comma-separated zones ("loc,dmz"), or exclusions ("all!net").
Action PolicyAction `yaml:"action"` Source string `yaml:"source"`
Log string `yaml:"log,omitempty"`
RateLimit string `yaml:"rate_limit,omitempty"` // Dest zone(s). Same syntax as Source.
ConnLimit int `yaml:"conn_limit,omitempty"` 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 { func (c *Config) validatePolicy() error {
@@ -26,6 +49,8 @@ func (c *Config) validatePolicy() error {
return fmt.Errorf("no policies defined") return fmt.Errorf("no policies defined")
} }
fwZone := c.FirewallZone()
for i, p := range c.Policy { for i, p := range c.Policy {
if p.Source == "" { if p.Source == "" {
return fmt.Errorf("policy[%d]: source required", i) return fmt.Errorf("policy[%d]: source required", i)
@@ -33,21 +58,86 @@ func (c *Config) validatePolicy() error {
if p.Dest == "" { if p.Dest == "" {
return fmt.Errorf("policy[%d]: dest required", i) 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 { switch p.Action {
case PolicyAccept, PolicyDrop, PolicyReject, PolicyContinue, PolicyNone: case PolicyAccept, PolicyDrop, PolicyReject, PolicyContinue, PolicyNone, PolicyQueue, PolicyNFQueue:
default: default:
return fmt.Errorf("policy[%d]: unknown action %q", i, p.Action) 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 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
}
+117
View File
@@ -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
}
+38
View File
@@ -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
}
+37
View File
@@ -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
}
+43
View File
@@ -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
}
+62
View File
@@ -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
}
+142 -25
View File
@@ -11,25 +11,98 @@ const (
RuleDNAT RuleAction = "dnat" RuleDNAT RuleAction = "dnat"
RuleRedirect RuleAction = "redirect" RuleRedirect RuleAction = "redirect"
RuleLog RuleAction = "log" 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 { type Rule struct {
Action RuleAction `yaml:"action"` Action RuleAction `yaml:"action"`
Source string `yaml:"source"` Section RuleSection `yaml:"section,omitempty"`
Dest string `yaml:"dest"`
Proto string `yaml:"proto,omitempty"` // Source zone spec. Supports:
DPort PortSpec `yaml:"dport,omitempty"` // zone, zone:address, zone:interface, zone:interface:address
SPort PortSpec `yaml:"sport,omitempty"` // all, all+, any, none, all!zone1,zone2
PortGroup string `yaml:"portgroup,omitempty"` // Multiple zones: loc,dmz
Log string `yaml:"log,omitempty"` Source string `yaml:"source"`
DNATDest string `yaml:"dnat_dest,omitempty"`
RateLimit string `yaml:"rate_limit,omitempty"` // Dest zone spec. Same syntax as Source.
ConnLimit int `yaml:"conn_limit,omitempty"` // For DNAT: zone:server-ip:port[:random]
Comment string `yaml:"comment,omitempty"` // 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. // PortSpec supports single ports, ranges, and lists.
// Examples: [80], [443], [80, 443], ["1024-65535"], [53, "80-90"] // 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 type PortSpec []string
func (ps *PortSpec) UnmarshalYAML(unmarshal func(interface{}) error) error { 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") 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 { func (c *Config) validateRules() error {
fwZone := c.FirewallZone()
for i, r := range c.Rules { for i, r := range c.Rules {
switch r.Action { if !validRuleActions[r.Action] {
case RuleAccept, RuleDrop, RuleReject, RuleDNAT, RuleRedirect, RuleLog:
default:
return fmt.Errorf("rule[%d]: unknown action %q", i, 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 == "" { if r.Source == "" {
return fmt.Errorf("rule[%d]: source required", i) 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) return fmt.Errorf("rule[%d]: dest required", i)
} }
srcZone := zoneFromSpec(r.Source) if r.Source != "all" && r.Source != "any" && r.Source != "none" &&
if srcZone != "all" { !hasPrefix(r.Source, "all+") && !hasPrefix(r.Source, "all!") && !hasPrefix(r.Source, "any!") {
if _, ok := c.Zones[srcZone]; !ok { for _, srcPart := range splitZones(r.Source) {
return fmt.Errorf("rule[%d]: source zone %q not defined", i, srcZone) 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 r.Action != RuleDNAT && r.Action != RuleRedirect && r.Action != RuleNoNAT {
if dstZone != "all" { if r.Dest != "all" && r.Dest != "any" && r.Dest != "none" &&
if _, ok := c.Zones[dstZone]; !ok { !hasPrefix(r.Dest, "all+") && !hasPrefix(r.Dest, "all!") && !hasPrefix(r.Dest, "any!") {
return fmt.Errorf("rule[%d]: dest zone %q not defined", i, dstZone) 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 == "" { if r.Action == RuleDNAT && r.Dest == "" {
return fmt.Errorf("rule[%d]: dnat_dest required for DNAT action", i) 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 return nil
@@ -119,3 +232,7 @@ func zoneFromSpec(spec string) string {
} }
return spec return spec
} }
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
+27
View File
@@ -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
}
+68 -10
View File
@@ -7,22 +7,68 @@ type SNATAction string
const ( const (
SNATMasquerade SNATAction = "masquerade" SNATMasquerade SNATAction = "masquerade"
SNATAddress SNATAction = "snat" 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 { type SNATRule struct {
Action SNATAction `yaml:"action"` Action SNATAction `yaml:"action"`
Address string `yaml:"address,omitempty"`
Source string `yaml:"source,omitempty"` // For SNAT: the source address (or address range first-last) to rewrite to.
DestInterface string `yaml:"dest_interface"` // Supports: single IP, IP range (1.2.3.4-1.2.3.7), or "detect" (use interface addresses).
Proto string `yaml:"proto,omitempty"` Address string `yaml:"address,omitempty"`
DPort PortSpec `yaml:"dport,omitempty"`
Comment string `yaml:"comment,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 { func (c *Config) validateSNAT() error {
for i, s := range c.SNAT { for i, s := range c.SNAT {
switch s.Action { switch s.Action {
case SNATMasquerade, SNATAddress: case SNATMasquerade, SNATAddress, SNATContinue, SNATLog:
default: default:
return fmt.Errorf("snat[%d]: unknown action %q", i, s.Action) 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) return fmt.Errorf("snat[%d]: address required for snat action", i)
} }
if s.DestInterface == "" { if s.Dest == "" {
return fmt.Errorf("snat[%d]: dest_interface required", i) 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 return nil
+58
View File
@@ -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
}
+128
View File
@@ -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
}
+89
View File
@@ -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
}
+49 -8
View File
@@ -1,6 +1,9 @@
package config package config
import "fmt" import (
"fmt"
"unicode"
)
type ZoneType string type ZoneType string
@@ -8,13 +11,22 @@ const (
ZoneIP ZoneType = "ip" ZoneIP ZoneType = "ip"
ZoneIPSec ZoneType = "ipsec" ZoneIPSec ZoneType = "ipsec"
ZoneFirewall ZoneType = "firewall" ZoneFirewall ZoneType = "firewall"
ZoneBPort ZoneType = "bport"
ZoneLoopback ZoneType = "loopback" ZoneLoopback ZoneType = "loopback"
ZoneLocal ZoneType = "local"
) )
type Zone struct { type Zone struct {
Type ZoneType `yaml:"type"` Type ZoneType `yaml:"type"`
Parent string `yaml:"parent,omitempty"` Parents []string `yaml:"parents,omitempty"`
Options []string `yaml:"options,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 { func (c *Config) validateZones() error {
@@ -24,17 +36,28 @@ func (c *Config) validateZones() error {
firewallCount := 0 firewallCount := 0
for name, z := range c.Zones { for name, z := range c.Zones {
if err := validateZoneName(name); err != nil {
return fmt.Errorf("zone %q: %w", name, err)
}
switch z.Type { 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: default:
return fmt.Errorf("zone %q: unknown type %q", name, z.Type) return fmt.Errorf("zone %q: unknown type %q", name, z.Type)
} }
if z.Type == ZoneFirewall { if z.Type == ZoneFirewall {
firewallCount++ 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 { for _, parent := range z.Parents {
return fmt.Errorf("zone %q: parent zone %q not defined", name, z.Parent) 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 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
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -64,7 +64,13 @@ func computeDiff(current, desired *FirewallState) *ChangeSet {
} }
for tag, desiredRules := range desiredByTag { 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...) cs.Add = append(cs.Add, desiredRules...)
} }
} }
@@ -77,3 +83,27 @@ func computeDiff(current, desired *FirewallState) *ChangeSet {
return cs 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
}
File diff suppressed because it is too large Load Diff
+727
View File
@@ -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")
}
})
}
+172
View File
@@ -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()
}
+413
View File
@@ -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")
}
})
}
+70
View File
@@ -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
+171 -9
View File
@@ -2,9 +2,12 @@
# Spiritual successor to shorewall — manages nftables directly # Spiritual successor to shorewall — manages nftables directly
settings: settings:
# address_family: inet (default), ip (IPv4 only), ip6 (IPv6 only)
address_family: inet
ip_forwarding: true ip_forwarding: true
log_level: info log_level: info
table_name: tomswall table_name: tomswall
implicit_continue: false
# Named port groups — reusable port+protocol combos referenced in rules # Named port groups — reusable port+protocol combos referenced in rules
portgroups: portgroups:
@@ -28,6 +31,7 @@ portgroups:
ports: ["1024-65535"] ports: ["1024-65535"]
# Security zones (replaces /etc/shorewall/zones) # Security zones (replaces /etc/shorewall/zones)
# Child zones are listed before parents; nesting via parents field.
zones: zones:
fw: fw:
type: firewall type: firewall
@@ -37,6 +41,10 @@ zones:
type: ip type: ip
dmz: dmz:
type: ip 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) # Interface-to-zone mappings (replaces /etc/shorewall/interfaces)
interfaces: interfaces:
@@ -44,14 +52,18 @@ interfaces:
interface: eth0 interface: eth0
options: options:
dhcp: true dhcp: true
tcpflags: true
nosmurfs: true nosmurfs: true
routefilter: 1
logmartians: true
- zone: loc - zone: loc
interface: eth1 interface: eth1
options:
mss: 1400
- zone: dmz - zone: dmz
interface: eth2 interface: eth2
# Host definitions (replaces /etc/shorewall/hosts) # Host definitions (replaces /etc/shorewall/hosts)
# Only needed when multiple zones share an interface.
hosts: hosts:
- zone: loc - zone: loc
interface: eth1 interface: eth1
@@ -59,7 +71,8 @@ hosts:
- 192.168.1.0/24 - 192.168.1.0/24
# Default zone-to-zone policies (replaces /etc/shorewall/policy) # 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: policy:
- source: fw - source: fw
dest: all dest: all
@@ -80,15 +93,12 @@ policy:
log: info log: info
# Specific traffic rules (replaces /etc/shorewall/rules) # Specific traffic rules (replaces /etc/shorewall/rules)
# Supports zone:address notation, e.g. source: "net:203.0.113.0/24"
rules: rules:
# Allow SSH from local network to firewall
- action: accept - action: accept
source: loc source: loc
dest: fw dest: fw
portgroup: ssh portgroup: ssh
# Allow DNS from local network
- action: accept - action: accept
source: loc source: loc
dest: net dest: net
@@ -98,26 +108,178 @@ rules:
dest: net dest: net
portgroup: dns_tcp portgroup: dns_tcp
# Allow web traffic from net to DMZ
- action: accept - action: accept
source: net source: net
dest: dmz dest: dmz
portgroup: web portgroup: web
# Allow ping from local network
- action: accept - action: accept
source: loc source: loc
dest: fw dest: fw
proto: icmp proto: icmp
# Drop all other ICMP from net
- action: drop - action: drop
source: net source: net
dest: all dest: all
proto: icmp 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) # Source NAT rules (replaces /etc/shorewall/snat)
# First match wins.
snat: snat:
- action: masquerade - action: masquerade
source: 192.168.1.0/24 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