Files
tomswall/internal/agent/agent_test.go
T
benvin 06928bc150
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Agent: translate the NAT tier into native config
The agent now maps the rendered NAT sections into native tomswall config:
- snat/masquerade -> config.SNAT, expanding a rendered rule's egress interface
  list and source CIDRs into one native rule per (egress, source) pair (a native
  SNAT rule takes a single dest interface); carries address/probability.
- netmap -> config.Netmap (from_net/to_net -> net1/net2 on the resolved interface).
- 1:1 nat -> config.StaticNAT.
Unit-tested end to end from RenderedConfig to config.Config.
2026-07-21 22:21:24 +10:00

241 lines
7.4 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 TestTranslateNATTier(t *testing.T) {
prob := 0.5
rc := &RenderedConfig{
Enforcing: true,
SNAT: []RenderedSNAT{
{Action: "masquerade", Source: []string{"10.1.0.0/24"}, Egress: []string{"eth0", "eth3"}},
{Action: "snat", Source: []string{"10.2.0.0/24"}, Egress: []string{"eth0"}, Address: "203.0.113.1", Probability: &prob},
},
Netmap: []RenderedNetmap{{Type: "dnat", FromNet: "10.0.0.0/24", ToNet: "192.168.1.0/24", Interface: "eth0"}},
NAT: []RenderedNAT{{External: "203.0.113.10", Internal: "10.1.0.10", Interface: "eth0"}},
}
cfg, err := Translate(rc)
if err != nil {
t.Fatalf("Translate: %v", err)
}
// masquerade with two egress interfaces expands to two rules; snat adds one.
if len(cfg.SNAT) != 3 {
t.Fatalf("expected 3 SNAT rules, got %d: %+v", len(cfg.SNAT), cfg.SNAT)
}
if cfg.SNAT[0].Action != config.SNATMasquerade || cfg.SNAT[0].Source != "10.1.0.0/24" || cfg.SNAT[0].Dest != "eth0" {
t.Errorf("unexpected masquerade rule: %+v", cfg.SNAT[0])
}
if cfg.SNAT[2].Address != "203.0.113.1" || cfg.SNAT[2].Probability != 0.5 {
t.Errorf("snat address/probability not carried: %+v", cfg.SNAT[2])
}
if len(cfg.Netmap) != 1 || cfg.Netmap[0].Net1 != "10.0.0.0/24" || cfg.Netmap[0].Net2 != "192.168.1.0/24" || cfg.Netmap[0].Interface != "eth0" {
t.Errorf("netmap not translated: %+v", cfg.Netmap)
}
if len(cfg.StaticNAT) != 1 || cfg.StaticNAT[0].External != "203.0.113.10" || cfg.StaticNAT[0].Internal != "10.1.0.10" {
t.Errorf("static nat not translated: %+v", cfg.StaticNAT)
}
}
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")
}
}