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:
+227
-13
@@ -1,13 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.unkin.net/unkin/tomswall/internal/config"
|
||||
"git.unkin.net/unkin/tomswall/internal/nftables"
|
||||
"git.unkin.net/unkin/tomswall/internal/shorewall"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
@@ -16,11 +20,27 @@ func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "tomswall",
|
||||
Short: "nftables firewall manager — spiritual successor to shorewall",
|
||||
Long: `tomswall is a firewall manager that interacts directly with the kernel's
|
||||
nftables subsystem via the google/nftables library. It supports differential
|
||||
rule application — no firewall teardown/rebuild needed.
|
||||
|
||||
Configuration can be provided in YAML, JSON, or legacy shorewall format.
|
||||
Use 'tomswall migrate' to convert a shorewall config to YAML.`,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
root.PersistentFlags().StringVarP(&configPath, "config", "c", "/etc/tomswall/tomswall.yaml", "path to configuration file")
|
||||
root.PersistentFlags().StringVarP(&configPath, "config", "c", "/etc/tomswall/tomswall.yaml", "path to configuration file or shorewall directory")
|
||||
|
||||
root.AddCommand(applyCmd(), checkCmd(), statusCmd(), purgeCmd(), flushCmd())
|
||||
root.AddCommand(
|
||||
applyCmd(),
|
||||
planCmd(),
|
||||
validateCmd(),
|
||||
statusCmd(),
|
||||
purgeCmd(),
|
||||
flushCmd(),
|
||||
migrateCmd(),
|
||||
completionCmd(),
|
||||
)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
@@ -28,10 +48,24 @@ func main() {
|
||||
}
|
||||
|
||||
func loadConfig() (*config.Config, error) {
|
||||
cfg, err := config.Load(configPath)
|
||||
info, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("config path %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var cfg *config.Config
|
||||
if info.IsDir() {
|
||||
cfg, err = shorewall.Convert(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting shorewall config: %w", err)
|
||||
}
|
||||
} else {
|
||||
cfg, err = config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation: %w", err)
|
||||
}
|
||||
@@ -39,10 +73,12 @@ func loadConfig() (*config.Config, error) {
|
||||
}
|
||||
|
||||
func applyCmd() *cobra.Command {
|
||||
var dryRun bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "Apply configuration to nftables (differential)",
|
||||
Long: `Apply computes the difference between the desired configuration and the
|
||||
current nftables state, then applies only the necessary changes atomically.
|
||||
The firewall is never torn down — existing connections are preserved.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
@@ -66,10 +102,6 @@ func applyCmd() *cobra.Command {
|
||||
|
||||
fmt.Println(changes.Summary())
|
||||
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := engine.Apply(changes); err != nil {
|
||||
return fmt.Errorf("applying changes: %w", err)
|
||||
}
|
||||
@@ -77,14 +109,53 @@ func applyCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show planned changes without applying")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func checkCmd() *cobra.Command {
|
||||
func planCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Validate configuration without applying",
|
||||
Use: "plan",
|
||||
Short: "Show planned changes without applying (dry-run)",
|
||||
Long: `Plan computes the difference between the desired configuration and the
|
||||
current nftables state and displays what would change, without modifying
|
||||
the firewall.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
engine, err := nftables.NewEngine(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initializing nftables: %w", err)
|
||||
}
|
||||
|
||||
changes, err := engine.Plan()
|
||||
if err != nil {
|
||||
return fmt.Errorf("computing changes: %w", err)
|
||||
}
|
||||
|
||||
if changes.Empty() {
|
||||
fmt.Println("No changes needed — firewall is up to date.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Planned changes:")
|
||||
fmt.Println(changes.Summary())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validateCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate configuration files",
|
||||
Long: `Validate loads and validates the configuration without connecting to
|
||||
nftables. Checks all config sections for correctness: zones, interfaces,
|
||||
hosts, policy, rules, SNAT, NAT, netmap, providers, and all other sections.
|
||||
|
||||
Accepts YAML, JSON, or a shorewall config directory.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := loadConfig()
|
||||
if err != nil {
|
||||
@@ -213,3 +284,146 @@ func flushCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func migrateCmd() *cobra.Command {
|
||||
var outputFormat string
|
||||
var outputPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate [shorewall-dir]",
|
||||
Short: "Convert a shorewall/shorewall6 config directory to tomswall format",
|
||||
Long: `Migrate reads a shorewall or shorewall6 configuration directory and converts
|
||||
it to tomswall YAML or JSON format. The original config is never modified.
|
||||
Auto-detects IPv6 mode when shorewall6.conf is present.
|
||||
|
||||
Examples:
|
||||
tomswall migrate # /etc/shorewall -> stdout
|
||||
tomswall migrate /etc/shorewall -o config.yaml
|
||||
tomswall migrate /etc/shorewall6 -o config6.yaml
|
||||
tomswall migrate /etc/shorewall -f json -o config.json`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := "/etc/shorewall"
|
||||
if len(args) > 0 {
|
||||
dir = args[0]
|
||||
}
|
||||
|
||||
if !shorewall.DirExists(dir) {
|
||||
return fmt.Errorf("not a valid shorewall config directory: %s", dir)
|
||||
}
|
||||
|
||||
cfg, err := shorewall.Convert(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting: %w", err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: converted config has validation issues: %v\n", err)
|
||||
}
|
||||
|
||||
var data []byte
|
||||
switch strings.ToLower(outputFormat) {
|
||||
case "json":
|
||||
data, err = json.MarshalIndent(cfg, "", " ")
|
||||
default:
|
||||
data, err = cfg.ToYAML()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("serializing: %w", err)
|
||||
}
|
||||
|
||||
if outputPath == "" || outputPath == "-" {
|
||||
fmt.Print(string(data))
|
||||
} else {
|
||||
dir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("creating output directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing output: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Written to %s\n", outputPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&outputFormat, "format", "f", "yaml", "output format: yaml or json")
|
||||
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output file path (default: stdout)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func completionCmd() *cobra.Command {
|
||||
var install bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion [bash|zsh]",
|
||||
Short: "Generate shell completion scripts",
|
||||
Long: `Generate shell completion scripts for bash or zsh.
|
||||
|
||||
To load completions in the current session:
|
||||
source <(tomswall completion bash)
|
||||
source <(tomswall completion zsh)
|
||||
|
||||
To install completions permanently:
|
||||
tomswall completion bash --install
|
||||
tomswall completion zsh --install`,
|
||||
ValidArgs: []string{"bash", "zsh"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
root := cmd.Root()
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
if install {
|
||||
path := "/etc/bash_completion.d/tomswall"
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
home, _ := os.UserHomeDir()
|
||||
path = filepath.Join(home, ".local", "share", "bash-completion", "completions", "tomswall")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating completion directory: %w", err)
|
||||
}
|
||||
f, err = os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating completion file: %w", err)
|
||||
}
|
||||
}
|
||||
defer f.Close()
|
||||
if err := root.GenBashCompletionV2(f, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Bash completion installed to %s\n", path)
|
||||
return nil
|
||||
}
|
||||
return root.GenBashCompletionV2(os.Stdout, true)
|
||||
case "zsh":
|
||||
if install {
|
||||
path := "/usr/local/share/zsh/site-functions/_tomswall"
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
home, _ := os.UserHomeDir()
|
||||
path = filepath.Join(home, ".local", "share", "zsh", "site-functions", "_tomswall")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating completion directory: %w", err)
|
||||
}
|
||||
f, err = os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating completion file: %w", err)
|
||||
}
|
||||
}
|
||||
defer f.Close()
|
||||
if err := root.GenZshCompletion(f); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Zsh completion installed to %s\n", path)
|
||||
return nil
|
||||
}
|
||||
return root.GenZshCompletion(os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell: %s (use bash or zsh)", args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&install, "install", false, "install completion file to system/user directory")
|
||||
return cmd
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user