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:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
tomswall
|
/tomswall
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
+227
-13
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
+1370
-92
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Executable
+70
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user