POST /rules/{ref}/reset-quota takes an optional address (one entry of a
per-address quota starts again) and give (bytes taken off what was used,
one address's or everyone's, until the next reset). The firewall keeps
the adjustment for its next apply, which writes the new usage into the
ruleset; housecarld applies at once and saves the usage. hc rule
reset-quota --address/--give; Reset and Give back in the web UI, per
address in the usage dialog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1307 lines
54 KiB
Go
1307 lines
54 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|