Add tomswall agent (control-plane pull mode)

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.
This commit is contained in:
benvin
2026-07-20 20:05:49 +10:00
parent 8d9a76c751
commit e0f54ef320
20 changed files with 1414 additions and 82 deletions
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"git.unkin.net/unkin/tomswall/internal/agent"
)
func agentCmd() *cobra.Command {
var (
apiURL string
device string
token string
cachePath string
interval time.Duration
once bool
)
cmd := &cobra.Command{
Use: "agent",
Short: "Pull compiled config from tomswallapi and apply it continuously",
Long: `Agent runs the control-plane pull loop: it fetches this device's compiled
config from tomswallapi, differentially applies it, and reports the applied
generation back. It caches the last known-good config and, if the control plane
is unreachable, keeps applying that cache — it never fails closed.
The agent token defaults to the TOMSWALL_AGENT_TOKEN environment variable, and
the device name defaults to the system hostname.`,
RunE: func(cmd *cobra.Command, args []string) error {
if token == "" {
token = os.Getenv("TOMSWALL_AGENT_TOKEN")
}
if device == "" {
device, _ = os.Hostname()
}
if apiURL == "" {
return fmt.Errorf("--api-url is required (or set it in the environment)")
}
if device == "" {
return fmt.Errorf("--device is required (could not determine hostname)")
}
if token == "" {
return fmt.Errorf("agent token required: set --token or TOMSWALL_AGENT_TOKEN")
}
a := &agent.Agent{
Client: agent.NewClient(apiURL, device, token),
Cache: agent.Cache{Path: cachePath},
Interval: interval,
Applier: agent.EngineApplier{},
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if once {
return a.RunOnce(ctx)
}
return a.Run(ctx)
},
}
cmd.Flags().StringVar(&apiURL, "api-url", os.Getenv("TOMSWALL_API_URL"), "tomswallapi base URL (or TOMSWALL_API_URL)")
cmd.Flags().StringVar(&device, "device", "", "device name (defaults to hostname)")
cmd.Flags().StringVar(&token, "token", "", "agent bearer token (or TOMSWALL_AGENT_TOKEN)")
cmd.Flags().StringVar(&cachePath, "cache", "/var/lib/tomswall/rendered.yaml", "path to the last-known-good config cache")
cmd.Flags().DurationVar(&interval, "interval", time.Minute, "poll interval")
cmd.Flags().BoolVar(&once, "once", false, "run a single apply cycle and exit")
return cmd
}
+1
View File
@@ -39,6 +39,7 @@ Use 'tomswall migrate' to convert a shorewall config to YAML.`,
purgeCmd(),
flushCmd(),
migrateCmd(),
agentCmd(),
completionCmd(),
)