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.
206 lines
5.9 KiB
Go
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")
|
|
}
|
|
}
|