package server import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/netip" "os" "strings" "sync" "testing" "time" "housecarl/internal/api" "housecarl/internal/auth" "housecarl/internal/engine" "housecarl/internal/model" "housecarl/internal/nft" "housecarl/internal/render" "housecarl/internal/store" ) // fakeFW stands in for nftables: it records the applied states and keeps // dynamic sets' entries. type fakeFW struct { mu sync.Mutex applied []*model.State boot *model.State elements map[string][]render.Element applyErr error deleted []string restored []string resets []string } func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil } func (f *fakeFW) Apply(_ context.Context, st *model.State) error { f.mu.Lock() defer f.mu.Unlock() if f.applyErr != nil { return f.applyErr } f.applied = append(f.applied, st.Clone()) return nil } func (f *fakeFW) last() *model.State { f.mu.Lock() defer f.mu.Unlock() return f.applied[len(f.applied)-1] } func (f *fakeFW) Render(st *model.State) *render.Ruleset { return render.Render(st, render.Options{}) } func (f *fakeFW) Table(context.Context) (*nft.Table, error) { f.mu.Lock() defer f.mu.Unlock() if len(f.applied) == 0 { return nil, errors.New("No such file or directory: list table inet housecarl") } return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements}, nil } func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error { f.mu.Lock() defer f.mu.Unlock() f.elements[set] = append(f.elements[set], render.Element{Addr: addr, Timeout: timeout}) return nil } func (f *fakeFW) DeleteElement(_ context.Context, set, addr string) error { f.mu.Lock() defer f.mu.Unlock() keep := f.elements[set][:0] for _, e := range f.elements[set] { if e.Addr != addr { keep = append(keep, e) } } f.elements[set] = keep return nil } func (f *fakeFW) ListRuleset(context.Context) ([]byte, error) { return []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"inet","name":"housecarl"}}]}`), nil } func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error { f.mu.Lock() defer f.mu.Unlock() f.deleted = append(f.deleted, fam+" "+name) return nil } func (f *fakeFW) ResetQuota(_ context.Context, id int64, perAddress bool) error { f.mu.Lock() defer f.mu.Unlock() f.resets = append(f.resets, fmt.Sprintf("r%d:%v", id, perAddress)) return nil } func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) { return "table " + fam + " " + name + " {}", nil } func (f *fakeFW) ApplyScript(_ context.Context, script string) error { f.mu.Lock() defer f.mu.Unlock() f.restored = append(f.restored, script) return nil } func (f *fakeFW) SaveBoot(st *model.State) error { f.mu.Lock() defer f.mu.Unlock() f.boot = st.Clone() return nil } type hostIfaces map[string][]netip.Prefix func (h hostIfaces) All() (map[string][]netip.Prefix, error) { return h, nil } func (h hostIfaces) Up(string) bool { return true } type env struct { t *testing.T ts *httptest.Server srv *Server eng *engine.Engine fw *fakeFW token string // the admin's API token } func newEnv(t *testing.T) *env { t.Helper() ctx := context.Background() st, err := store.Open(":memory:") if err != nil { t.Fatal(err) } hash, _ := auth.HashPassword("secret-pass-1") admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true} if err := st.CreateUser(ctx, admin); err != nil { t.Fatal(err) } tok, pid, sh := auth.NewToken() st.CreateToken(ctx, &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh}) eng, err := engine.New(ctx, st, nil) if err != nil { t.Fatal(err) } fw := &fakeFW{elements: map[string][]render.Element{}} eng.Firewall = fw ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}} srv := New(Config{}, eng, fw, ifs, nil, nil) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return &env{t: t, ts: ts, srv: srv, eng: eng, fw: fw, token: tok} } // call makes a request with a token; it returns the status and decodes // the body into out (if given). func (e *env) call(token, method, path string, body any, out any) int { e.t.Helper() var rd io.Reader if body != nil { b, _ := json.Marshal(body) rd = bytes.NewReader(b) } req, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { e.t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) if out != nil && len(b) > 0 { if err := json.Unmarshal(b, out); err != nil { e.t.Fatalf("%s %s: %v: %s", method, path, err, b) } } return resp.StatusCode } func ptr[T any](v T) *T { return &v } func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) } func TestLoginSessionAndCSRF(t *testing.T) { e := newEnv(t) jar, _ := cookiejar.New(nil) c := &http.Client{Jar: jar} post := func(path, body, csrf string) *http.Response { req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") if csrf != "" { req.Header.Set("X-CSRF-Token", csrf) } resp, err := c.Do(req) if err != nil { t.Fatal(err) } return resp } if r := post("/auth/login", `{"username":"admin","password":"wrong"}`, ""); r.StatusCode != 401 { t.Errorf("wrong password: %d", r.StatusCode) } r := post("/auth/login", `{"username":"admin","password":"secret-pass-1"}`, "") var me api.Whoami json.NewDecoder(r.Body).Decode(&me) if r.StatusCode != 200 || me.CSRFToken == "" || !me.Admin { t.Fatalf("login: %d %+v", r.StatusCode, me) } body := `{"name":"lan","interfaces":["eth1"]}` if r := post("/zones", body, ""); r.StatusCode != 403 { t.Errorf("change without CSRF token: %d", r.StatusCode) } if r := post("/zones", body, me.CSRFToken); r.StatusCode != 201 { b, _ := io.ReadAll(r.Body) t.Errorf("change with CSRF token: %d %s", r.StatusCode, b) } h, _ := e.srv.store.ListHistory(context.Background(), 1, 0) if len(h) != 1 || h[0].Via != "web" || h[0].Username != "admin" || h[0].Description != "create zone lan" { t.Errorf("history %+v", h) } } func TestObjectsFlow(t *testing.T) { e := newEnv(t) var stat api.Status if e.call(e.token, "GET", "/status", nil, &stat); !stat.Unconfigured || stat.Applied || stat.ApplyError != "" { t.Errorf("empty configuration: %+v", stat) } var mr api.MutationResponse if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr); code != 201 || !mr.Applied { t.Fatalf("zone: %d %+v", code, mr) } e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil) if code := e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24", "2a02:8013:7f3d:10::0/64"}}, &mr); code != 201 { t.Fatalf("set: %d %+v", code, mr) } var set model.AddressSet json.Unmarshal(mr.Object, &set) if set.Entries[1] != "2a02:8013:7f3d:10::/64" { t.Errorf("set entries not normalized: %v", set.Entries) } // Rules go to the end of their chain. var ids []int64 for _, svc := range []string{"ssh", "dns", "https"} { var r model.Rule e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{svc}}}, &mr) json.Unmarshal(mr.Object, &r) if r.Action != "accept" { t.Errorf("default action %q", r.Action) } ids = append(ids, r.ID) } // Move https first; PATCH changes only the given fields. if code := e.call(e.token, "POST", "/rules/"+itoa(ids[2])+"/move", api.MoveInput{To: "start"}, nil); code != 200 { t.Errorf("move: %d", code) } if code := e.call(e.token, "PATCH", "/rules/"+itoa(ids[0]), map[string]any{"comment": "admins", "log": true}, &mr); code != 200 { t.Errorf("patch: %d %+v", code, mr) } var rules []model.Rule e.call(e.token, "GET", "/rules?chain=input", nil, &rules) if len(rules) != 3 || rules[0].ID != ids[2] || rules[1].Comment != "admins" || !rules[1].Log || rules[1].Sources[0] != "trusted" { t.Errorf("rules after move and patch: %+v", rules) } // Validation: an unknown service, refused with its code. var eb api.ErrorBody if code := e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"gopher"}}}, &eb); code != 422 || eb.Error.Problems[0].Code != "unknown_service" { t.Errorf("unknown service: %d %+v", code, eb) } // Unknown fields are refused. if code := e.call(e.token, "POST", "/zones", map[string]any{"name": "x", "interfaces": []string{"x"}, "colour": "red"}, &eb); code != 400 { t.Errorf("unknown field: %d", code) } // A set in use can't be deleted. if code := e.call(e.token, "DELETE", "/sets/trusted", nil, &eb); code != 409 || !strings.Contains(eb.Error.Message, "used by input") { t.Errorf("deleting a used set: %d %+v", code, eb) } // Services list includes the built-in ones. var svcs []model.Service e.call(e.token, "GET", "/services", nil, &svcs) found := false for _, s := range svcs { found = found || s.Name == "ssh" && s.Builtin } if !found { t.Errorf("no built-in ssh in %+v", svcs) } // Settings merge, built-ins one by one. if code := e.call(e.token, "PATCH", "/settings", map[string]any{"builtins": map[string]bool{"mss_clamp": true}, "own_prefixes": []string{"2a02:8013:7f3d::/48"}}, &mr); code != 200 { t.Fatalf("settings: %d %+v", code, mr) } s := e.eng.State().Settings if !s.Builtins.MSSClamp || !s.Builtins.Established || s.OwnPrefixes[0] != "2a02:8013:7f3d::/48" { t.Errorf("settings %+v", s) } // The firewall got the latest state; the ruleset is there to see. if len(e.fw.last().Rules) != 3 { t.Errorf("applied %+v", e.fw.last().Rules) } req, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+"/ruleset", nil) req.Header.Set("Authorization", "Bearer "+e.token) resp, _ := http.DefaultClient.Do(req) b, _ := io.ReadAll(resp.Body) if !strings.Contains(string(b), "ip6 saddr @trusted_v6 udp dport 53 counter accept") { t.Errorf("ruleset:\n%s", b) } var st api.Status if code := e.call(e.token, "GET", "/status", nil, &st); code != 200 || st.Counts["rule"] != 3 || len(st.OtherTables) != 1 || st.OtherTables[0] != "inet main" { t.Errorf("status: %d %+v", code, st) } var ifs []api.HostInterface e.call(e.token, "GET", "/interfaces", nil, &ifs) if len(ifs) != 2 || ifs[0].Name != "eth1" || ifs[0].Zone != "lan" { t.Errorf("interfaces %+v", ifs) } } func TestProbation(t *testing.T) { e := newEnv(t) e.srv.probationFor = 300 * time.Millisecond ctx := context.Background() // A network change: pending, then rolled back by itself. var mr api.MutationResponse e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr) var pr api.Probation e.call(e.token, "GET", "/probation", nil, &pr) if len(pr.Pending) != 1 || pr.Pending[0] != mr.HistoryID || pr.SecondsLeft > 1 { t.Fatalf("probation %+v", pr) } if v, _ := e.srv.store.GetMeta(ctx, probationKey); !strings.Contains(v, itoa(mr.HistoryID)) { t.Errorf("probation not saved: %q", v) } time.Sleep(700 * time.Millisecond) if len(e.eng.State().Zones) != 0 || len(e.fw.last().Zones) != 0 { t.Errorf("not rolled back: state %+v applied %+v", e.eng.State().Zones, e.fw.last().Zones) } e.call(e.token, "GET", "/probation", nil, &pr) if len(pr.Pending) != 0 { t.Errorf("still pending: %+v", pr) } // Confirmed in time: kept, and saved for the boot. e.srv.probationFor = 5 * time.Second e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil) e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil) var conf map[string]int if code := e.call(e.token, "POST", "/confirm", nil, &conf); code != 200 || conf["confirmed"] != 2 { t.Errorf("confirm: %d %+v", code, conf) } if len(e.eng.State().Zones) != 2 || e.fw.boot == nil || len(e.fw.boot.Zones) != 2 { t.Errorf("after confirm: state %+v boot %+v", e.eng.State().Zones, e.fw.boot) } // Through the socket: no probation, saved for the boot at once. sock := &auth.Principal{User: &model.User{Username: "root", Admin: true}, Method: "socket"} res, err := e.eng.Apply(ctx, sock, "", engine.Options{Via: "cli"}, func(st *model.State, newID func() int64) error { st.Settings.ForwardPolicy = model.PolicyAccept return nil }) if err != nil { t.Fatal(err) } e.srv.afterChange(ctx, sock, res) if len(e.srv.probationStatus().Pending) != 0 || e.fw.boot.Settings.ForwardPolicy != model.PolicyAccept { t.Errorf("socket change: %+v boot %s", e.srv.probationStatus(), e.fw.boot.Settings.ForwardPolicy) } // Pending when housecarld stops: rolled back when it starts. e.srv.probationFor = time.Hour e.call(e.token, "PATCH", "/zones/lan", map[string]any{"interfaces": []string{"eth1", "eth2"}}, nil) srv2 := New(Config{}, e.eng, e.fw, nil, nil, nil) if !srv2.ResumeProbation(ctx) || len(e.eng.State().Zone("lan").Interfaces) != 1 { t.Errorf("restart rollback: %+v", e.eng.State().Zone("lan")) } h, _ := e.srv.store.ListHistory(ctx, 1, 0) if h[0].Via != "rollback" || h[0].Username != "housecarl" { t.Errorf("rollback history %+v", h[0]) } } func TestDynamicSetElements(t *testing.T) { e := newEnv(t) e.call(e.token, "POST", "/sets", model.AddressSet{Name: "guests", Dynamic: true, Timeout: 3600}, nil) e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, nil) // A token that may only change the guests set (a captive portal). var tok api.Token if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "portal", Grants: []string{"sets:write:guests"}}, &tok); code != 201 { t.Fatalf("token: %d", code) } if code := e.call(tok.Secret, "POST", "/sets/guests/elements", api.Element{Address: "10.0.30.5", Timeout: 1800}, nil); code != 201 { t.Errorf("add: %d", code) } if code := e.call(tok.Secret, "POST", "/sets/guests/elements", api.Element{Address: "10.0.30.0/24"}, nil); code != 400 { t.Errorf("a prefix: %d", code) } var els []api.Element e.call(tok.Secret, "GET", "/sets/guests/elements", nil, &els) if len(els) != 1 || els[0].Address != "10.0.30.5" || els[0].Timeout != 1800 { t.Errorf("elements %+v", els) } if code := e.call(tok.Secret, "POST", "/sets/trusted/elements", api.Element{Address: "10.0.10.9"}, nil); code != 403 { t.Errorf("another set: %d", code) } if code := e.call(e.token, "POST", "/sets/trusted/elements", api.Element{Address: "10.0.10.9"}, nil); code != 400 { t.Errorf("a static set: %d", code) } if code := e.call(tok.Secret, "PATCH", "/sets/guests", map[string]any{"timeout": 60}, nil); code != 403 { t.Errorf("changing the set itself: %d", code) } if code := e.call(tok.Secret, "DELETE", "/sets/guests/elements/10.0.30.5", nil, nil); code != 204 { t.Errorf("delete: %d", code) } evs, _ := e.srv.store.ListAudit(context.Background(), store.AuditFilter{Action: "set."}) if len(evs) != 2 || evs[0].Action != "set.delete" { t.Errorf("audit %+v", evs) } } func TestPermissions(t *testing.T) { e := newEnv(t) var tok api.Token if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok); code != 201 || tok.Secret == "" { t.Fatalf("token: %d %+v", code, tok) } if code := e.call(tok.Secret, "GET", "/counters", nil, nil); code != 200 { t.Errorf("counters with a log:read token: %d", code) } if code := e.call(tok.Secret, "GET", "/rules", nil, nil); code != 403 { t.Errorf("rules with a log:read token: %d", code) } var eb api.ErrorBody if code := e.call(tok.Secret, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &eb); code != 403 || !strings.Contains(eb.Error.Message, "config:write") { t.Errorf("change with a read token: %d %+v", code, eb) } if code := e.call(tok.Secret, "GET", "/users", nil, nil); code != 403 { t.Errorf("users with a restricted token: %d", code) } // A restricted token can't make a wider one. if code := e.call(tok.Secret, "POST", "/me/tokens", api.TokenInput{Name: "wider", Role: "operator"}, nil); code != 403 { t.Errorf("wider token: %d", code) } if code := e.call("", "GET", "/status", nil, nil); code != 401 { t.Errorf("no token: %d", code) } if code := e.call("housecarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil); code != 401 { t.Errorf("bad token: %d", code) } // The last administrator stays. var users []api.User e.call(e.token, "GET", "/users", nil, &users) if code := e.call(e.token, "PATCH", "/users/"+itoa(users[0].ID), api.UserInput{Admin: ptr(false)}, &eb); code != 409 { t.Errorf("demoting the last admin: %d", code) } } func TestTokenRotation(t *testing.T) { e := newEnv(t) var tok api.Token e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "script", ExpiresIn: "30d"}, &tok) var rot api.Token if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "1h"}, &rot); code != 200 || rot.Secret == "" || rot.Secret == tok.Secret || rot.PrevExpiresAt == nil { t.Fatalf("rotate: %d %+v", code, rot) } if rot.ID != tok.ID || rot.Name != "script" || rot.ExpiresAt == nil || rot.ExpiresAt.Sub(*tok.ExpiresAt) < 0 { t.Errorf("rotated token %+v", rot) } if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { t.Errorf("new secret: %d", code) } if code := e.call(tok.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { t.Errorf("old secret in its grace period: %d", code) } var me api.Whoami e.call(rot.Secret, "GET", "/auth/whoami", nil, &me) if me.TokenID != tok.ID { t.Errorf("whoami token id %d, want %d", me.TokenID, tok.ID) } // Without a grace period the old secret stops at once. var rot2 api.Token e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, &rot2) if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 401 { t.Errorf("old secret without grace: %d", code) } if code := e.call(rot2.Secret, "GET", "/auth/whoami", nil, nil); code != 200 { t.Errorf("newest secret: %d", code) } if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "30d"}, nil); code != 400 { t.Errorf("a grace over 7 days: %d", code) } // A restricted token can't rotate a wider one. var narrow api.Token e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "narrow", Role: "log"}, &narrow) if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, nil); code != 403 { t.Errorf("narrow token rotating a wider one: %d", code) } if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(narrow.ID)+"/rotate", nil, nil); code != 200 { t.Errorf("a token rotating itself: %d", code) } } func TestImportAndTakeover(t *testing.T) { e := newEnv(t) e.srv.probationFor = 300 * time.Millisecond js, _ := os.ReadFile("../importer/testdata/router.json") txt, _ := os.ReadFile("../importer/testdata/router.txt") defs, _ := os.ReadFile("../importer/testdata/router.nft") in := api.ImportInput{JSON: js, Text: string(txt), Defines: string(defs)} var res api.ImportResult if code := e.call(e.token, "POST", "/import/nft?dry_run=1", in, &res); code != 200 || !res.DryRun || len(res.State.Forwards) != 5 || len(res.Notes) == 0 { t.Fatalf("dry run: %d %+v", code, res.MutationResponse) } if len(e.eng.State().Rules) != 0 { t.Error("a dry run changed the configuration") } if code := e.call(e.token, "POST", "/import/nft", in, &res); code != 200 || !res.Applied || res.ConfirmBy == nil { t.Fatalf("import: %d %+v", code, res.MutationResponse) } st := e.eng.State() if len(st.Forwards) != 5 || st.Zone("wan") == nil || st.Forwards[0].ID < 2 { t.Errorf("imported: %+v", st.Forwards) } // IDs are the store's: a new object doesn't collide. var mr api.MutationResponse if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "extra", Interfaces: []string{"eth9"}}, &mr); code != 201 { t.Errorf("a zone after the import: %d %+v", code, mr) } // A second import needs --replace. if code := e.call(e.token, "POST", "/import/nft", in, nil); code != 409 { t.Errorf("import over a configuration: %d", code) } e.call(e.token, "POST", "/confirm", nil, nil) // Takeover: the old table goes, and comes back unless confirmed. var out map[string]any if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet main"}}, &out); code != 200 || len(e.fw.deleted) != 1 { t.Fatalf("takeover: %d %+v", code, out) } if !strings.Contains(out["message"].(string), "come back") { t.Errorf("takeover message %q", out["message"]) } time.Sleep(700 * time.Millisecond) if len(e.fw.restored) != 1 || e.fw.restored[0] != "table inet main {}" { t.Errorf("not restored: %+v", e.fw.restored) } if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet housecarl"}}, nil); code != 400 { t.Errorf("taking over Housecarl's own table: %d", code) } } // TestChangesets: changes staged in a changeset are checked as they are // added, shown in reads that name the changeset, previewed with the // ruleset's diff, and applied together as one history entry, on // probation; another user can't use the changeset. func TestChangesets(t *testing.T) { e := newEnv(t) e.srv.probationFor = 5 * time.Second e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil) e.call(e.token, "POST", "/confirm", nil, nil) applied := len(e.fw.applied) var cs model.Changeset if code := e.call(e.token, "POST", "/changesets", api.ChangesetInput{Description: "trusted ssh"}, &cs); code != 201 || cs.Status != "open" { t.Fatalf("create: %d %+v", code, cs) } q := "?changeset=" + itoa(cs.ID) // A rule naming a set the changeset hasn't made yet is refused. var eb api.ErrorBody rule := model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Action: "accept"} if code := e.call(e.token, "POST", "/rules"+q, rule, &eb); code != 422 || eb.Error.Code != "validation" { t.Errorf("staging an invalid rule: %d %+v", code, eb) } var mr api.MutationResponse if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, &mr); code != 200 || !mr.Staged || mr.Applied { t.Fatalf("stage set: %d %+v", code, mr) } if code := e.call(e.token, "POST", "/rules"+q, rule, &mr); code != 200 || !mr.Staged || len(mr.Changeset.Ops) != 2 { t.Fatalf("stage rule: %d %+v", code, mr) } var created model.Rule json.Unmarshal(mr.Object, &created) // Later operations can name what the changeset made. if code := e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+q, map[string]any{"comment": "SSH from home"}, &mr); code != 200 || len(mr.Changeset.Ops) != 3 { t.Fatalf("stage update: %d %+v", code, mr) } if code := e.call(e.token, "DELETE", "/zones/lan"+q, nil, &mr); code != 200 { t.Fatalf("stage delete: %d %+v", code, mr) } // Nothing applied yet. if len(e.fw.applied) != applied || len(e.eng.State().Sets) != 0 { t.Fatalf("staging applied something") } // Reads naming the changeset show it, marked. var zones []map[string]any e.call(e.token, "GET", "/zones"+q, nil, &zones) if len(zones) != 1 || zones[0]["pending"] != "deleted" { t.Errorf("zones in the changeset: %+v", zones) } var rules []map[string]any e.call(e.token, "GET", "/rules"+q+"&chain=input", nil, &rules) if len(rules) != 1 || rules[0]["pending"] != "new" || rules[0]["comment"] != "SSH from home" { t.Errorf("rules in the changeset: %+v", rules) } var plain []map[string]any e.call(e.token, "GET", "/zones", nil, &plain) if len(plain) != 1 || plain[0]["pending"] != nil { t.Errorf("zones without the changeset: %+v", plain) } // The preview: valid, with the ruleset's diff. var pv api.ChangesetPreview e.call(e.token, "GET", "/changesets/"+itoa(cs.ID)+"/preview", nil, &pv) if !pv.Valid || !strings.Contains(pv.Diff, "+\t\tip saddr @trusted_v4 tcp dport 22 counter accept") || !strings.Contains(pv.Diff, "--- ruleset.nft (now)") { t.Errorf("preview: %+v", pv) } // Another user can't stage into it, apply or discard it. ctx := context.Background() other := &model.User{Username: "sam", Grants: auth.Roles["operator"]} e.srv.store.CreateUser(ctx, other) otok, pid, sh := auth.NewToken() e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh}) if code := e.call(otok, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &eb); code != 403 || !strings.Contains(eb.Error.Message, "admin's") { t.Errorf("another user's apply: %d %+v", code, eb) } // Removing an operation; then the changeset is applied as one change. var after model.Changeset if code := e.call(e.token, "DELETE", "/changesets/"+itoa(cs.ID)+"/ops/4", nil, &after); code != 200 || len(after.Ops) != 3 { t.Fatalf("remove op: %d %+v", code, after) } if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied || mr.ConfirmBy == nil || mr.Changeset.Status != "applied" { t.Fatalf("apply: %d %+v", code, mr) } if len(e.fw.applied) != applied+1 || len(e.eng.State().Rules) != 1 || len(e.eng.State().Zones) != 1 { t.Errorf("applied: %d rulesets, state %+v", len(e.fw.applied)-applied, e.eng.State()) } h, _ := e.srv.store.GetHistory(ctx, mr.HistoryID) if h.Description != "trusted ssh" || len(h.Changes) != 2 { t.Errorf("history: %+v", h) } // An applied changeset takes no more. if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "x", Entries: []string{"10.0.0.1"}}, &eb); code != 409 || !strings.Contains(eb.Error.Message, "is applied") { t.Errorf("staging into an applied changeset: %d %+v", code, eb) } // A changeset that no longer applies names the operation. var cs2 model.Changeset e.call(e.token, "POST", "/changesets", nil, &cs2) e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+"?changeset="+itoa(cs2.ID), map[string]any{"comment": "x"}, nil) e.call(e.token, "DELETE", "/rules/"+itoa(created.ID), nil, nil) e.call(e.token, "GET", "/changesets/"+itoa(cs2.ID)+"/preview", nil, &pv) if pv.Valid || pv.Error == nil || pv.Error.Op != 1 || !strings.Contains(pv.Error.Message, "operation 1") { t.Errorf("stale preview: %+v", pv) } if code := e.call(e.token, "POST", "/changesets/"+itoa(cs2.ID)+"/discard", nil, &after); code != 200 || after.Status != "discarded" { t.Errorf("discard: %d %+v", code, after) } // A dry run shows the ruleset's diff. if code := e.call(e.token, "POST", "/sets?dry_run=1", model.AddressSet{Name: "y", Entries: []string{"10.0.0.2"}}, &mr); code != 200 || !strings.Contains(mr.Diff, "+\tset y_v4") { t.Errorf("dry run diff: %d %q", code, mr.Diff) } } func TestQuotaPeriods(t *testing.T) { at := func(s string) time.Time { v, _ := time.ParseInLocation("2006-01-02 15:04", s, time.Local); return v } for _, c := range []struct { now, reset string day int start string next string }{ {"2026-10-19 10:00", "daily", 0, "2026-10-19 00:00", "2026-10-20 00:00"}, {"2026-10-19 10:00", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Monday {"2026-10-25 23:59", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Sunday {"2026-10-19 10:00", "monthly", 15, "2026-10-15 00:00", "2026-11-15 00:00"}, {"2026-10-10 10:00", "monthly", 15, "2026-09-15 00:00", "2026-10-15 00:00"}, {"2026-02-28 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"}, // February has no 31st {"2026-03-15 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"}, {"2026-01-01 00:00", "monthly", 0, "2026-01-01 00:00", "2026-02-01 00:00"}, } { now := at(c.now) if got := periodStart(now, c.reset, c.day); !got.Equal(at(c.start)) { t.Errorf("%s %s %d: period from %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.start) } if got := nextReset(now, c.reset, c.day); !got.Equal(at(c.next)) { t.Errorf("%s %s %d: next reset %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.next) } } if !periodStart(time.Now(), "", 0).IsZero() { t.Error("a quota reset by hand has no period") } } // TestQuotaResets: a quota starts its period when first seen, is reset // when the next one starts, and by hand. func TestQuotaResets(t *testing.T) { e := newEnv(t) ctx := context.Background() now := time.Date(2026, 10, 19, 23, 50, 0, 0, time.Local) e.srv.nowFn = func() time.Time { return now } var mr api.MutationResponse rule := model.Rule{Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}} if code := e.call(e.token, "POST", "/rules", rule, &mr); code != 201 { t.Fatalf("rule: %d %+v", code, mr) } var created model.Rule json.Unmarshal(mr.Object, &created) e.srv.checkQuotas(ctx) if len(e.fw.resets) != 0 { t.Fatalf("reset on first sight: %v", e.fw.resets) } now = now.Add(5 * time.Minute) // the same day e.srv.checkQuotas(ctx) now = now.Add(10 * time.Minute) // past midnight e.srv.checkQuotas(ctx) e.srv.checkQuotas(ctx) want := fmt.Sprintf("r%d:true", created.ID) if strings.Join(e.fw.resets, ",") != want { t.Errorf("resets %v, want one %s", e.fw.resets, want) } var qs []api.QuotaInfo e.call(e.token, "GET", "/quotas", nil, &qs) if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 { t.Errorf("quotas %+v", qs) } if code := e.call(e.token, "POST", "/rules/"+itoa(created.ID)+"/reset-quota", nil, nil); code != 200 || len(e.fw.resets) != 2 { t.Errorf("reset by hand: %d %v", code, e.fw.resets) } } // TestAutoConfirm: the client that made a change confirms it by itself: // with its token, from the same address, over a new connection. Nobody // else can, an older token can't, and with manual_confirm there is none. func TestAutoConfirm(t *testing.T) { e := newEnv(t) e.srv.probationFor = 5 * time.Second post := func(token, path string, body any) (*http.Response, []byte) { b, _ := json.Marshal(body) req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, bytes.NewReader(b)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() out, _ := io.ReadAll(resp.Body) return resp, out } resp, body := post(e.token, "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}) var mr api.MutationResponse json.Unmarshal(body, &mr) if mr.ConfirmToken == "" || !resp.Close { t.Fatalf("no token, or the connection stays open: %+v close=%v", mr, resp.Close) } first := mr.ConfirmToken // A newer change: only its token confirms now. _, body = post(e.token, "/zones", model.Zone{Name: "dmz", Interfaces: []string{"eth2"}}) json.Unmarshal(body, &mr) if resp, _ := post(e.token, "/confirm", api.ConfirmInput{Token: first}); resp.StatusCode != 409 { t.Errorf("an older token: %d", resp.StatusCode) } // Another user can't use it. ctx := context.Background() other := &model.User{Username: "sam", Grants: auth.Roles["operator"]} e.srv.store.CreateUser(ctx, other) otok, pid, sh := auth.NewToken() e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh}) if resp, _ := post(otok, "/confirm", api.ConfirmInput{Token: mr.ConfirmToken}); resp.StatusCode != 403 { t.Errorf("another user's confirmation: %d", resp.StatusCode) } // Not over the change's own connection, nor from elsewhere. c := e.srv.probation.check p := &auth.Principal{User: &model.User{ID: c.userID, Username: c.username}, Method: "token", Token: &model.Token{ID: c.tokenID}} same := httptest.NewRequest("POST", "/", nil) same.RemoteAddr = c.remote if _, err := e.srv.confirmToken(same, p, c.token); err == nil || !strings.Contains(err.Error(), "new one") { t.Errorf("over the same connection: %v", err) } elsewhere := httptest.NewRequest("POST", "/", nil) elsewhere.RemoteAddr = "192.0.2.9:4444" if _, err := e.srv.confirmToken(elsewhere, p, c.token); err == nil || !strings.Contains(err.Error(), "same place") { t.Errorf("from elsewhere: %v", err) } // The client itself, over a new connection: confirmed. resp, body = post(e.token, "/confirm", api.ConfirmInput{Token: mr.ConfirmToken}) if resp.StatusCode != 200 || !strings.Contains(string(body), `"confirmed":2`) { t.Fatalf("auto-confirm: %d %s", resp.StatusCode, body) } if pr := e.srv.probationStatus(); len(pr.Pending) != 0 { t.Errorf("still pending: %+v", pr) } // manual_confirm: no token; a person confirms. e.call(e.token, "PATCH", "/settings", map[string]any{"manual_confirm": true}, nil) e.call(e.token, "POST", "/confirm", nil, nil) _, body = post(e.token, "/zones", model.Zone{Name: "guest", Interfaces: []string{"eth3"}}) mr = api.MutationResponse{} json.Unmarshal(body, &mr) if mr.ConfirmToken != "" || mr.ConfirmBy == nil { t.Errorf("manual confirmation: %+v", mr) } }