Files
tomswall/internal/agent/agent_test.go
T
benvin e0f54ef320 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.
2026-07-20 20:05:49 +10:00

206 lines
5.9 KiB
Go

package agent
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"git.unkin.net/unkin/tomswall/internal/config"
)
func TestTranslateInterfaceAgnosticRule(t *testing.T) {
rc := &RenderedConfig{
Generation: 5,
Device: "fw-a",
Enforcing: true,
Settings: RenderedSettings{AddressFamily: "inet", LogLevel: "info", TableName: "tomswall"},
Bindings: map[string][]string{"zone-a": {"eth1"}},
Sets: []RenderedSet{
{Name: "asn_cloudflare", Kind: "asn", Members: []string{"104.16.0.0/13", "1.1.1.0/24"}},
},
Rules: []RenderedRule{
{
Action: "accept",
Source: []RenderedMatch{{Zone: "zone-a", Subnets: []string{"10.1.0.0/24"}}},
Dest: []RenderedMatch{{Zone: "net", Set: "asn_cloudflare"}},
Proto: "tcp",
Ports: []string{"443"},
},
},
}
cfg, err := Translate(rc)
if err != nil {
t.Fatalf("Translate: %v", err)
}
// fw zone + zone-a.
if _, ok := cfg.Zones["fw"]; !ok {
t.Error("missing firewall zone")
}
if _, ok := cfg.Zones["zone-a"]; !ok {
t.Error("missing zone-a")
}
// binding -> interface.
if len(cfg.Interfaces) != 1 || cfg.Interfaces[0].Interface != "eth1" {
t.Errorf("expected one eth1 interface, got %+v", cfg.Interfaces)
}
// 1 source addr x 2 dest addrs (asn set members) = 2 rules.
if len(cfg.Rules) != 2 {
t.Fatalf("expected 2 expanded rules, got %d: %+v", len(cfg.Rules), cfg.Rules)
}
for _, r := range cfg.Rules {
if r.Source != "all:10.1.0.0/24" {
t.Errorf("source not interface-agnostic saddr match: %q", r.Source)
}
if r.Action != config.RuleAccept || r.Proto != "tcp" || len(r.DPort) != 1 || r.DPort[0] != "443" {
t.Errorf("unexpected rule: %+v", r)
}
}
dests := map[string]bool{cfg.Rules[0].Dest: true, cfg.Rules[1].Dest: true}
if !dests["all:104.16.0.0/13"] || !dests["all:1.1.1.0/24"] {
t.Errorf("dest set members not inlined: %v", dests)
}
}
func TestTranslateBareZone(t *testing.T) {
rc := &RenderedConfig{
Enforcing: true,
Rules: []RenderedRule{{
Action: "accept",
Source: []RenderedMatch{{Zone: "zone-a", Subnets: []string{"10.1.0.0/24"}}},
Dest: []RenderedMatch{{Zone: "zone-b", Subnets: []string{"10.4.0.0/24"}}},
Proto: "tcp", Ports: []string{"22"},
}},
}
cfg, err := Translate(rc)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if len(cfg.Rules) != 1 || cfg.Rules[0].Source != "all:10.1.0.0/24" || cfg.Rules[0].Dest != "all:10.4.0.0/24" {
t.Errorf("unexpected zone-to-zone rule: %+v", cfg.Rules)
}
}
func TestTranslateRejectsUnknownAction(t *testing.T) {
rc := &RenderedConfig{Enforcing: true, Rules: []RenderedRule{{Action: "bogus"}}}
if _, err := Translate(rc); err == nil {
t.Fatal("expected error for unknown action")
}
}
// fakeApplier records applied configs.
type fakeApplier struct {
count int32
lastGen int
}
func (f *fakeApplier) Apply(_ context.Context, cfg *config.Config) error {
atomic.AddInt32(&f.count, 1)
f.lastGen = len(cfg.Rules)
return nil
}
const renderedYAML = `generation: 7
device: fw-a
enforcing: true
settings:
address_family: inet
log_level: info
table_name: tomswall
bindings:
zone-a: [eth1]
rules:
- action: accept
source:
- zone: zone-a
subnets: ["10.1.0.0/24"]
dest:
- zone: zone-b
subnets: ["10.4.0.0/24"]
proto: tcp
ports: ["22"]
`
func TestRunOnceAppliesAndReports(t *testing.T) {
var reported int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/devices/fw-a/config":
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte(renderedYAML))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/devices/fw-a/status":
atomic.StoreInt64(&reported, 1)
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
applier := &fakeApplier{}
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: filepath.Join(t.TempDir(), "cache.yaml")},
Applier: applier,
}
if err := a.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if atomic.LoadInt32(&applier.count) != 1 {
t.Errorf("expected 1 apply, got %d", applier.count)
}
if atomic.LoadInt64(&reported) != 1 {
t.Error("expected status to be reported")
}
// Cache should now be populated.
if cached, err := a.Cache.Read(); err != nil || cached == nil || cached.Generation != 7 {
t.Errorf("cache not written correctly: %+v (err %v)", cached, err)
}
}
func TestRunOnceFallsBackToCacheNeverFailsClosed(t *testing.T) {
// Server always errors — the control plane is "unreachable".
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
cachePath := filepath.Join(t.TempDir(), "cache.yaml")
if err := (Cache{Path: cachePath}).Write([]byte(renderedYAML)); err != nil {
t.Fatalf("seed cache: %v", err)
}
applier := &fakeApplier{}
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: cachePath},
Applier: applier,
}
// Fetch fails, but the cached config must still be applied (fail-safe).
if err := a.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce should not error when a cache exists: %v", err)
}
if atomic.LoadInt32(&applier.count) != 1 {
t.Errorf("expected cached config to be applied, got %d applies", applier.count)
}
}
func TestRunOnceNoCacheReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: filepath.Join(t.TempDir(), "absent.yaml")},
Applier: &fakeApplier{},
}
if err := a.RunOnce(context.Background()); err == nil {
t.Fatal("expected error when unreachable and no cache exists")
}
}