e0f54ef320
Add `tomswall agent`: it pulls this device's compiled config from tomswallapi, differentially applies it, and reports the applied generation. It caches the last known-good config and, when the control plane is unreachable, keeps applying that cache — it never fails closed. - internal/agent: rendered-config types, HTTP client (fetch + status report), on-disk cache, on-device DNS resolver for dns sets (honors the device's configured resolver, fail-safe on lookup failure), and the pull-apply-report loop behind a mockable Applier. - Translate the interface-agnostic, address-matched rendered model into native tomswall config using the "all:<cidr>" any-interface source/dest form, reusing the existing differential engine. Named-set members are inlined as concrete addresses (native nft set references are a tracked follow-up). - cmd/tomswall: wire the `agent` subcommand (flags + TOMSWALL_* env, --once). - Unit tests: translation, cache, and the don't-fail-closed fallback loop. - Add DESIGN.md documenting the control-plane architecture.
431 lines
11 KiB
Go
431 lines
11 KiB
Go
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
|
|
|
|
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(),
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
engine, err := nftables.NewEngine(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("initializing nftables: %w", err)
|
|
}
|
|
|
|
foreign, err := engine.FindForeignRules()
|
|
if err != nil {
|
|
return fmt.Errorf("scanning: %w", err)
|
|
}
|
|
|
|
if len(foreign) == 0 {
|
|
fmt.Println("No foreign rules found.")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("Found %d foreign rule(s) to remove:\n", len(foreign))
|
|
for _, r := range foreign {
|
|
fmt.Printf(" - %s\n", r)
|
|
}
|
|
|
|
if dryRun {
|
|
return nil
|
|
}
|
|
|
|
if err := engine.PurgeForeignRules(foreign); err != nil {
|
|
return fmt.Errorf("purging: %w", err)
|
|
}
|
|
fmt.Println("Foreign rules removed.")
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show foreign rules without removing")
|
|
return cmd
|
|
}
|
|
|
|
func flushCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "flush",
|
|
Short: "Remove all tomswall-managed rules and tables",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
engine, err := nftables.NewEngine(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("initializing nftables: %w", err)
|
|
}
|
|
|
|
if err := engine.Flush(); err != nil {
|
|
return fmt.Errorf("flushing: %w", err)
|
|
}
|
|
fmt.Println("All tomswall rules flushed.")
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|