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") } }