Files
abrown38andClaude Opus 5.5 aaf0ac5206 Quotas: save their usage after every change
The server hooks the engine's OnChange to save what the quotas used, so
a reboot soon after a change that counts a quota differently doesn't
bring back its usage counted the old way (it was saved every five
minutes and on stop only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:04:04 +01:00

1322 lines
55 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"os"
"reflect"
"strings"
"sync"
"testing"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
"housecarl/internal/names"
"housecarl/internal/nft"
"housecarl/internal/render"
"housecarl/internal/stats"
"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
traced string
traceLines []string
fallback bool // the boot fallback is in force
shadow bool
ruleset string // nft -j list ruleset ("": a default)
quotas nft.QuotaUsage
adjusted []nft.QuotaAdjust
}
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{Shadow: f.shadow})
}
func (f *fakeFW) RenderEnforcing(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, Fallback: f.fallback, QuotaUsage: f.quotas}, nil
}
func (f *fakeFW) AdjustQuota(a nft.QuotaAdjust) {
f.mu.Lock()
defer f.mu.Unlock()
f.adjusted = append(f.adjusted, a)
}
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) {
if f.ruleset != "" {
return []byte(f.ruleset), nil
}
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) Trace(ctx context.Context, script string, line func(string)) error {
f.mu.Lock()
f.traced = script
lines := f.traceLines
f.mu.Unlock()
for _, l := range lines {
line(l)
}
<-ctx.Done()
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()
return newEnvWith(t, Config{})
}
func newEnvWith(t *testing.T, cfg Config) *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{}, shadow: cfg.Shadow}
eng.Firewall = fw
ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}}
srv := New(cfg, 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)
}
reset := "/rules/" + itoa(created.ID) + "/reset-quota"
if code := e.call(e.token, "POST", reset, nil, nil); code != 200 || len(e.fw.resets) != 2 {
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
}
// One address started again, or some given back: the next apply
// (made at once) writes the new usage.
name := render.QuotaName(created.ID)
e.fw.quotas = nft.QuotaUsage{QuotaSets: map[string][]render.Element{name + "_v4": {{Addr: "10.0.10.23", Used: 6 << 30}},
name + "_v6": {{Addr: "2001:db8:1:2::", Used: 1 << 30}}}}
applies := len(e.fw.applied)
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "10.0.10.23"}, nil); code != 200 {
t.Errorf("reset one address: %d", code)
}
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "2001:db8:1:2:aaaa::7", Give: 2 << 30}, nil); code != 200 {
t.Errorf("give back to an IPv6 address (its /64): %d", code)
}
if code := e.call(e.token, "POST", reset, api.QuotaReset{Give: 1 << 30}, nil); code != 200 {
t.Errorf("give back to everyone: %d", code)
}
want3 := []nft.QuotaAdjust{{Name: name, Addr: "10.0.10.23", Give: math.MaxInt64}, {Name: name, Addr: "2001:db8:1:2::", Give: 2 << 30}, {Name: name, Give: 1 << 30}}
if !reflect.DeepEqual(e.fw.adjusted, want3) || len(e.fw.applied) != applies+3 || len(e.fw.resets) != 2 {
t.Errorf("adjusted %+v, applied %d more, resets %v", e.fw.adjusted, len(e.fw.applied)-applies, e.fw.resets)
}
for _, c := range []struct {
in api.QuotaReset
code int
}{{api.QuotaReset{Address: "10.0.10.99"}, 404}, {api.QuotaReset{Address: "nonsense"}, 400}, {api.QuotaReset{Give: -5}, 400}} {
if code := e.call(e.token, "POST", reset, c.in, nil); code != c.code {
t.Errorf("%+v: %d, want %d", c.in, code, c.code)
}
}
}
// TestQuotaUsageSavedOnChange: a change saves what the quotas used at
// once (a restart must not bring back usage counted the old way).
func TestQuotaUsageSavedOnChange(t *testing.T) {
e := newEnv(t)
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
e.fw.mu.Lock()
e.fw.quotas = nft.QuotaUsage{Quotas: map[string]int64{"quota_r7": 4242}}
e.fw.mu.Unlock()
e.call(e.token, "POST", "/zones", model.Zone{Name: "dmz", Interfaces: []string{"eth2"}}, nil)
v, err := e.eng.Store().GetMeta(context.Background(), QuotaUsageKey)
if err != nil || !strings.Contains(v, `"quota_r7":4242`) {
t.Errorf("saved usage %q %v", v, err)
}
}
// 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)
}
}
// TestStagedUndo: an undo staged in a changeset changes nothing until the
// changeset is applied; then History links the two entries.
func TestStagedUndo(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 5 * time.Second
var mr api.MutationResponse
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &mr)
e.call(e.token, "POST", "/confirm", nil, nil)
made := mr.HistoryID
var cs model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs)
q := "?changeset=" + itoa(cs.ID)
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &mr); code != 200 || !mr.Staged {
t.Fatalf("staging the undo: %d %+v", code, mr)
}
if op := mr.Changeset.Ops[0]; op.Op != model.OpRevert || !strings.HasPrefix(op.Summary, "undo "+itoa(made)+":") {
t.Errorf("the operation: %+v", op)
}
if len(e.eng.State().Zones) != 1 {
t.Fatal("a staged undo was applied")
}
// Staging it again can't work: the changeset already undoes it.
var eb api.ErrorBody
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &eb); code != 409 {
t.Errorf("a second undo: %d %+v", code, eb)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied {
t.Fatalf("apply: %d %+v", code, mr)
}
if len(e.eng.State().Zones) != 0 {
t.Error("the undo wasn't applied")
}
h, _ := e.srv.store.GetHistory(context.Background(), mr.HistoryID)
orig, _ := e.srv.store.GetHistory(context.Background(), made)
if h.RevertOf != made || orig.RevertedBy != h.ID || !strings.HasPrefix(h.Description, "undo ") {
t.Errorf("history: %+v, original %+v", h, orig)
}
}
// TestMatrix: the zone matrix, now and as a changeset will make it.
func TestMatrixEndpoint(t *testing.T) {
e := newEnv(t)
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
var m api.Matrix
e.call(e.token, "GET", "/matrix", nil, &m)
cell := func(from, to string) api.MatrixCell {
for _, c := range m.Cells {
if c.From == from && c.To == to {
return c
}
}
return api.MatrixCell{}
}
if c := cell("lan", "wan"); c.Verdict != "blocked" || c.DecidedBy != "forward policy" {
t.Errorf("lan → wan: %+v", c)
}
var cs model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs)
q := "?changeset=" + itoa(cs.ID)
e.call(e.token, "POST", "/rules"+q, model.Rule{Chain: "forward", Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: "accept"}, nil)
e.call(e.token, "GET", "/matrix"+q, nil, &m)
if c := cell("lan", "wan"); c.Verdict != "allowed" {
t.Errorf("lan → wan with the changeset: %+v", c)
}
// Hits: summed over an object's counters; none without a counter.
hm := api.Matrix{Cells: []api.MatrixCell{{Marks: []string{"p-forward"}, Exceptions: []api.MatrixException{
{Marks: []string{"b-antispoof", "b-antispoof6"}}, {Marks: []string{"r9"}}}}}}
matrixHits(&hm, map[string]nft.Counter{"p-forward": {Packets: 4}, "b-antispoof": {Packets: 2}, "b-antispoof6": {Packets: 3}})
if c := hm.Cells[0]; c.Hits == nil || *c.Hits != 4 || c.Exceptions[0].Hits == nil || *c.Exceptions[0].Hits != 5 || c.Exceptions[1].Hits != nil {
t.Errorf("hits: %+v", c)
}
}
// TestHostProblems: an enabled nftables.service, and an imported table
// still in force, are warned about.
func TestHostProblems(t *testing.T) {
e := newEnv(t)
dir := t.TempDir()
link := dir + "/nftables.service"
e.srv.unitLinks = []string{link}
codes := func() string {
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
var out []string
for _, p := range st.Problems {
out = append(out, p.Code)
}
return strings.Join(out, " ")
}
if c := codes(); strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") {
t.Errorf("warned without cause: %s", c)
}
os.Symlink("/usr/lib/systemd/system/nftables.service", link) // dangling is fine: Lstat
// The fake ruleset lists inet main: imported from it, it still applies.
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
if c := codes(); !strings.Contains(c, "nftables_service") || !strings.Contains(c, "takeover_pending") {
t.Errorf("not warned: %s", c)
}
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet gone")
if c := codes(); strings.Contains(c, "takeover_pending") {
t.Errorf("warned about a table that is gone: %s", c)
}
}
type fakeConns struct {
list []api.Connection
closed []api.Connection
}
func (f *fakeConns) List(context.Context) ([]api.Connection, error) { return f.list, nil }
func (f *fakeConns) Close(_ context.Context, c api.Connection) error {
f.closed = append(f.closed, c)
return nil
}
// TestConnections: connections listed and searched; closed one by one, or
// all a search finds; closing needs config:write, and is audited.
func TestConnections(t *testing.T) {
e := newEnv(t)
var code int
if code = e.call(e.token, "GET", "/connections", nil, nil); code != 503 {
t.Errorf("without conntrack: %d", code)
}
fc := &fakeConns{list: []api.Connection{
{Family: "ipv4", Protocol: "tcp", Source: "192.168.1.50", SourcePort: 51432, Destination: "198.51.100.9", DestinationPort: 443, SNATTo: "203.0.113.10", State: "established"},
{Family: "ipv4", Protocol: "udp", Source: "192.168.1.51", SourcePort: 5353, Destination: "192.168.1.1", DestinationPort: 53},
{Family: "ipv6", Protocol: "tcp", Source: "2001:db8:1::50", SourcePort: 40000, Destination: "2001:db8:2::9", DestinationPort: 4430},
}}
e.srv.SetConntrack(fc)
var cs []api.Connection
e.call(e.token, "GET", "/connections?q=192.168.1.50", nil, &cs)
if len(cs) != 1 || cs[0].DestinationPort != 443 {
t.Errorf("search by address: %+v", cs)
}
e.call(e.token, "GET", "/connections?q=:443", nil, &cs)
if len(cs) != 1 || cs[0].Source != "192.168.1.50" {
t.Errorf("search by port (not :4430): %+v", cs)
}
var out map[string]int
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{Search: "tcp"}, &out); code != 200 || out["closed"] != 2 || len(fc.closed) != 2 {
t.Errorf("close a search: %d %+v %+v", code, out, fc.closed)
}
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{}, nil); code != 400 {
t.Errorf("close nothing said: %d", code)
}
var tok api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok)
if code := e.call(tok.Secret, "GET", "/connections", nil, nil); code != 200 {
t.Errorf("listing with log:read: %d", code)
}
if code := e.call(tok.Secret, "POST", "/connections/close", api.CloseConnections{Search: "udp"}, nil); code != 403 {
t.Errorf("closing with log:read: %d", code)
}
}
// TestNames: names only for inside addresses (an inside zone's network,
// or private outside the external zones'), under the address as asked.
func TestNames(t *testing.T) {
var asked []string
var mu sync.Mutex
e := newEnvWith(t, Config{Names: names.NewFunc(func(_ context.Context, a string) ([]string, error) {
mu.Lock()
asked = append(asked, a)
mu.Unlock()
return []string{"host-" + strings.ReplaceAll(a, ".", "-") + ".example.net."}, nil
}, time.Hour)})
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 out api.Names
e.call(e.token, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5", "::ffff:10.0.10.5", "192.168.7.7", "217.155.8.194", "8.8.8.8", "fe80::1", "nonsense"}}, &out)
want := map[string]string{"10.0.10.5": "host-10-0-10-5.example.net", "::ffff:10.0.10.5": "host-10-0-10-5.example.net", "192.168.7.7": "host-192-168-7-7.example.net"}
if !out.Enabled || !reflect.DeepEqual(out.Names, want) {
t.Errorf("names: %+v", out)
}
if len(asked) != 2 {
t.Errorf("looked up %v, want the two inside addresses once each", asked)
}
var tok api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok)
var one, none api.Names
if code := e.call(tok.Secret, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5"}}, &one); code != 200 || len(one.Names) != 1 {
t.Errorf("with log:read: %d %+v", code, one)
}
off := newEnv(t)
off.call(off.token, "POST", "/names", api.NamesInput{Addresses: []string{"10.0.10.5"}}, &none)
if none.Enabled || len(none.Names) != 0 {
t.Errorf("names off: %+v", none)
}
}
// TestTrace: a trace runs with the filter's table, collects packets named
// after Housecarl's objects, and stops when asked; one at a time.
func TestTrace(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"tcp/9999"}}, Action: "drop", Comment: "block 9999"}, &mr)
var rule model.Rule
json.Unmarshal(mr.Object, &rule)
e.fw.traceLines = []string{
`trace id 1 inet housecarl input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 tcp sport 4000 tcp dport 9999 tcp flags == syn`,
`trace id 1 inet housecarl input rule ct state established,related accept comment "hc:b-established" (verdict continue)`,
fmt.Sprintf(`trace id 1 inet housecarl input rule tcp dport 9999 counter drop comment "hc:r%d block 9999" (verdict drop)`, rule.ID),
}
var st api.TraceStatus
if code := e.call(e.token, "POST", "/trace", api.TraceInput{Port: 9999, Seconds: 30}, &st); code != 200 || !st.Running {
t.Fatalf("start: %d %+v", code, st)
}
if code := e.call(e.token, "POST", "/trace", api.TraceInput{Port: 22}, nil); code != 409 {
t.Errorf("a second trace: %d", code)
}
time.Sleep(100 * time.Millisecond)
e.call(e.token, "GET", "/trace", nil, &st)
if len(st.Packets) != 1 || st.Packets[0].Verdict != "dropped" || st.Packets[0].DecidedBy.Label != "rule input #"+itoa(rule.ID)+" (block 9999)" ||
st.Packets[0].Steps[0].Label != "built-in: established" {
t.Errorf("packets: %+v", st.Packets)
}
if !strings.Contains(e.fw.traced, "th dport 9999") {
t.Errorf("the trace table: %s", e.fw.traced)
}
if code := e.call(e.token, "DELETE", "/trace", nil, &st); code != 200 || st.Running || len(st.Packets) != 1 {
t.Errorf("stop: %d %+v", code, st)
}
if code := e.call(e.token, "POST", "/trace", api.TraceInput{}, nil); code != 400 {
t.Errorf("tracing everything: %d", code)
}
}
// TestStatsEndpoints: statistics off, then on: the summary's top lists,
// hits per rule (named, with those that counted none), and which
// counters count drops.
func TestStatsEndpoints(t *testing.T) {
e := newEnv(t)
if code := e.call(e.token, "GET", "/stats/summary", nil, nil); code != http.StatusServiceUnavailable {
t.Errorf("statistics off: %d", code)
}
var st StatsStatus
e.call(e.token, "GET", "/stats/status", nil, &st)
if st.Enabled {
t.Error("enabled without a collector")
}
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Sources: []string{"198.51.100.0/24"}}, Action: "drop", Comment: "bad net"}, nil)
rule := e.eng.State().Rules[0]
ss, err := stats.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer ss.Close()
src := stats.Sources{Table: e.fw.Table, SysNet: t.TempDir(), Proc: t.TempDir()}
e.srv.StatsSources(&src)
c := stats.NewCollector(ss, stats.Options{RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour}, src, nil)
e.srv.SetStats(c)
c.Start(context.Background())
e.srv.LogPacket(api.LogEntry{Time: time.Now(), Source: "198.51.100.7", Protocol: "tcp", DestPort: 22, Verdict: "drop"})
c.Sample(context.Background())
var sum stats.Summary
if code := e.call(e.token, "GET", "/stats/summary?since=1h", nil, &sum); code != http.StatusOK {
t.Fatalf("summary: %d", code)
}
if len(sum.BlockedSources) != 1 || sum.BlockedSources[0].Key != "198.51.100.7" || len(sum.BlockedPorts) != 1 || sum.BlockedPorts[0].Key != "tcp/22" {
t.Errorf("summary: %+v", sum)
}
var hits []stats.Entry
e.call(e.token, "GET", "/stats/hits", nil, &hits)
if len(hits) != 1 || hits[0].Key != "r"+itoa(rule.ID) || hits[0].Count != 0 || !strings.Contains(hits[0].Name, "bad net") {
t.Errorf("hits: %+v", hits)
}
var top []stats.Entry
if code := e.call(e.token, "GET", "/stats/top?kind=blocked_port&since=7d", nil, &top); code != http.StatusOK || len(top) != 1 {
t.Errorf("top: %d %+v", code, top)
}
if code := e.call(e.token, "GET", "/stats/top?kind=nonsense", nil, nil); code != http.StatusBadRequest {
t.Errorf("an unknown list: %d", code)
}
if code := e.call(e.token, "GET", "/stats/timeline?keys=zrx:*,drops&since=2h", nil, nil); code != http.StatusOK {
t.Errorf("timeline: %d", code)
}
e.call(e.token, "GET", "/stats/status", nil, &st)
if !st.Enabled || st.LastSample == nil {
t.Errorf("status: %+v", st)
}
for mark, want := range map[string]string{"r" + itoa(rule.ID): "input true", "p-input": "input true", "p-output": "output false", "b-antispoof6": "early true", "b-established": " false"} {
chain, drop := e.srv.classifyMark(mark)
if got := fmt.Sprint(chain, " ", drop); got != want {
t.Errorf("%s: %q, want %q", mark, got, want)
}
}
}
// TestShadowMode: in shadow mode changes aren't on probation, what would
// change traffic is refused, and the status, health and log say so.
func TestShadowMode(t *testing.T) {
e := newEnvWith(t, Config{Shadow: true, ShadowPosition: "after"})
e.srv.SetConntrack(&fakeConns{})
link := t.TempDir() + "/nftables.service"
os.Symlink("/usr/lib/systemd/system/nftables.service", link)
e.srv.unitLinks = []string{link}
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
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.ConfirmToken != "" || mr.ConfirmBy != nil {
t.Errorf("a change: %d %+v", code, mr)
}
var pr api.Probation
e.call(e.token, "GET", "/probation", nil, &pr)
if len(pr.Pending) != 0 || e.fw.boot == nil {
t.Errorf("on probation in shadow mode: %+v (saved for the boot: %v)", pr, e.fw.boot != nil)
}
var er api.ErrorBody
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet main"}}, &er); code != 409 || !strings.Contains(er.Error.Message, "shadow mode") || len(e.fw.deleted) != 0 {
t.Errorf("takeover: %d %+v %v", code, er, e.fw.deleted)
}
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{Search: "tcp"}, &er); code != 409 {
t.Errorf("closing connections: %d", code)
}
if code := e.call(e.token, "POST", "/rules", model.Rule{Chain: model.ChainInput, Raw: "meta mark set 1"}, &er); code != 201 {
t.Fatalf("a raw rule: %d %+v", code, er)
}
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
var codes []string
for _, p := range st.Problems {
codes = append(codes, p.Code)
}
c := strings.Join(codes, " ")
if st.Mode != api.ModeShadow || st.ShadowPosition != "after" {
t.Errorf("status mode: %q %q", st.Mode, st.ShadowPosition)
}
// Expected in shadow mode: the other firewall, and its service.
if strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") || !strings.Contains(c, "shadow_unprotected") || !strings.Contains(c, "shadow_raw_left_out") {
t.Errorf("warnings: %s", c)
}
e.fw.ruleset = `{"nftables":[{"chain":{"family":"inet","table":"main","name":"input","type":"filter","hook":"input","prio":0,"policy":"drop"}}]}`
e.call(e.token, "GET", "/status", nil, &st)
for _, p := range st.Problems {
if p.Code == "shadow_unprotected" {
t.Error("unprotected while inet main filters")
}
}
var h health
resp, err := http.Get(e.ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
json.NewDecoder(resp.Body).Decode(&h)
resp.Body.Close()
if h.Mode != api.ModeShadow || !h.OK {
t.Errorf("healthz: %+v", h)
}
var who api.Whoami
e.call(e.token, "GET", "/auth/whoami", nil, &who)
if who.Mode != api.ModeShadow {
t.Errorf("whoami: %+v", who)
}
e.srv.LogPacket(api.LogEntry{Time: time.Now(), Prefix: "hc input policy drop: ", Verdict: "drop"})
var logs []api.LogEntry
e.call(e.token, "GET", "/log", nil, &logs)
if len(logs) != 1 || !logs[0].Shadow {
t.Errorf("log: %+v", logs)
}
shadow := e.text("/ruleset")
enforcing := e.text("/ruleset?enforcing=1")
if !strings.Contains(shadow, "Shadow mode") || strings.Contains(enforcing, "Shadow mode") {
t.Errorf("rulesets:\n%s\n---\n%s", shadow, enforcing)
}
// Enforcing, the same change goes on probation.
n := newEnv(t)
n.call(n.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
n.call(n.token, "GET", "/probation", nil, &pr)
if len(pr.Pending) != 1 {
t.Errorf("not on probation enforcing: %+v", pr)
}
n.call(n.token, "GET", "/status", nil, &st)
if st.Mode != api.ModeEnforcing {
t.Errorf("enforcing status mode: %q", st.Mode)
}
}
// text GETs a plain-text answer.
func (e *env) text(path string) string {
e.t.Helper()
req, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+path, nil)
req.Header.Set("Authorization", "Bearer "+e.token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// TestCreatePlaced: a rule created ?before= another is placed in the same
// change (one history entry), also when staged in a changeset.
func TestCreatePlaced(t *testing.T) {
e := newEnv(t)
var a, b, c api.MutationResponse
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"ssh"}}}, &a)
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"dns"}}}, &b)
var first model.Rule
json.Unmarshal(a.Object, &first)
if code := e.call(e.token, "POST", "/rules?before="+itoa(first.ID), model.Rule{Chain: "input", Match: model.Match{Services: []string{"http"}}, Comment: "placed"}, &c); code != 201 {
t.Fatalf("create placed: %d %+v", code, c)
}
order := func() string {
var out []string
for _, r := range e.eng.State().Rules {
out = append(out, strings.Join(r.Services, ""))
}
return strings.Join(out, ",")
}
if o := order(); o != "http,ssh,dns" {
t.Errorf("order: %s", o)
}
var hs []model.HistoryEntry
e.call(e.token, "GET", "/history", nil, &hs)
if len(hs) != 3 || len(hs[0].Changes) != 1 || hs[0].Changes[0].Action() != "create" {
t.Errorf("history: %d entries, newest %+v", len(hs), hs[0])
}
// Staged, the place goes with it.
var cs model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs)
e.call(e.token, "POST", "/rules?first=1&changeset="+itoa(cs.ID), model.Rule{Chain: "input", Match: model.Match{Services: []string{"ntp"}}}, nil)
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, nil); code != 200 {
t.Fatalf("apply: %d", code)
}
if o := order(); o != "ntp,http,ssh,dns" {
t.Errorf("order after the changeset: %s", o)
}
// A bad place: refused, nothing created.
if code := e.call(e.token, "POST", "/rules?before=99999", model.Rule{Chain: "input"}, nil); code != 400 {
t.Errorf("before a rule that isn't there: %d", code)
}
if n := len(e.eng.State().Rules); n != 4 {
t.Errorf("%d rules", n)
}
}