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" "git.unkin.net/unkin/tomswall/internal/tryapply" ) var configPath string 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 or shorewall directory") root.AddCommand( applyCmd(), tryCmd(), confirmCmd(), revertCmd(), planCmd(), validateCmd(), statusCmd(), purgeCmd(), flushCmd(), migrateCmd(), agentCmd(), completionCmd(), ) if err := root.Execute(); err != nil { os.Exit(1) } } func loadConfig() (*config.Config, error) { info, err := os.Stat(configPath) if err != nil { 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) } return cfg, nil } func applyCmd() *cobra.Command { 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 { return err } unlock, err := tryapply.Acquire() if err != nil { return err } defer unlock() engine, err := nftables.NewEngine(cfg) if err != nil { return fmt.Errorf("initializing nftables: %w", err) } changes, err := engine.Plan() if err != nil { return fmt.Errorf("computing changes: %w", err) } if changes.Empty() { fmt.Println("No changes needed — firewall is up to date.") return nil } fmt.Println(changes.Summary()) if err := engine.Apply(changes); err != nil { return fmt.Errorf("applying changes: %w", err) } fmt.Println("Changes applied successfully.") return nil }, } return cmd } func planCmd() *cobra.Command { return &cobra.Command{ 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 { return err } fmt.Println("Configuration is valid.") return nil }, } } func statusCmd() *cobra.Command { return &cobra.Command{ Use: "status", Short: "Show current firewall state and pending changes", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } engine, err := nftables.NewEngine(cfg) if err != nil { return fmt.Errorf("initializing nftables: %w", err) } changes, err := engine.Plan() if err != nil { return fmt.Errorf("computing changes: %w", err) } foreign, err := engine.FindForeignRules() if err != nil { return fmt.Errorf("scanning foreign rules: %w", err) } if changes.Empty() && len(foreign) == 0 { fmt.Println("Firewall is up to date. No foreign rules detected.") return nil } if !changes.Empty() { fmt.Println("Pending changes:") fmt.Println(changes.Summary()) } if len(foreign) > 0 { fmt.Printf("\nForeign rules detected (%d):\n", len(foreign)) for _, r := range foreign { fmt.Printf(" - %s\n", r) } fmt.Println("\nUse 'tomswall purge' to remove foreign rules.") } return nil }, } } func purgeCmd() *cobra.Command { var dryRun bool cmd := &cobra.Command{ Use: "purge", Short: "Remove rules not managed by tomswall", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } if !dryRun { unlock, err := tryapply.Acquire() if err != nil { return err } defer unlock() } engine, err := nftables.NewEngine(cfg) if err != nil { return fmt.Errorf("initializing nftables: %w", err) } foreign, err := engine.FindForeignRules() if err != nil { return fmt.Errorf("scanning: %w", err) } if len(foreign) == 0 { fmt.Println("No foreign rules found.") return nil } fmt.Printf("Found %d foreign rule(s) to remove:\n", len(foreign)) for _, r := range foreign { fmt.Printf(" - %s\n", r) } if dryRun { return nil } if err := engine.PurgeForeignRules(foreign); err != nil { return fmt.Errorf("purging: %w", err) } fmt.Println("Foreign rules removed.") return nil }, } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show foreign rules without removing") return cmd } func flushCmd() *cobra.Command { return &cobra.Command{ Use: "flush", Short: "Remove all tomswall-managed rules and tables", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { return err } unlock, err := tryapply.Acquire() if err != nil { return err } defer unlock() engine, err := nftables.NewEngine(cfg) if err != nil { return fmt.Errorf("initializing nftables: %w", err) } if err := engine.Flush(); err != nil { return fmt.Errorf("flushing: %w", err) } fmt.Println("All tomswall rules flushed.") return nil }, } } 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 }