model.QuotaCarry says what becomes of a quota's use when its rule changes how it counts; the firewall's apply follows it, comparing with the rules in force: per address to a total starts at the addresses' sum, wider groups add up their addresses, and what can't be carried (a total to per address, per source to per destination, finer groups) starts again, with the engine warning quota_restarts on that change. Before, per source to per destination kept the sources' use as destinations', and regrouping left old entries behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
7.7 KiB
Go
222 lines
7.7 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/store"
|
|
)
|
|
|
|
func newEngine(t *testing.T) *Engine {
|
|
t.Helper()
|
|
st, err := store.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e, err := New(context.Background(), st, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return e
|
|
}
|
|
|
|
var admin = &auth.Principal{User: &model.User{ID: 1, Username: "admin", Admin: true}, Method: "session"}
|
|
|
|
// fakeFirewall records what it was asked to apply.
|
|
type fakeFirewall struct {
|
|
applied []*model.State
|
|
checkErr error
|
|
applyErr error
|
|
}
|
|
|
|
func (f *fakeFirewall) Check(_ context.Context, st *model.State) error { return f.checkErr }
|
|
func (f *fakeFirewall) Apply(_ context.Context, st *model.State) error {
|
|
if f.applyErr != nil {
|
|
return f.applyErr
|
|
}
|
|
f.applied = append(f.applied, st.Clone())
|
|
return nil
|
|
}
|
|
|
|
func addWAN(t *testing.T, e *Engine) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
res, err := e.Apply(context.Background(), admin, "", Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
|
|
id = newID()
|
|
st.Zones = append(st.Zones, &model.Zone{ID: id, Name: "wan", Interfaces: []string{" wan ", "wan", "ppp*"}, External: true})
|
|
return nil
|
|
})
|
|
if err != nil || !res.Applied || res.HistoryID == 0 {
|
|
t.Fatalf("apply: %+v %v", res, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestApplyNormalizesAndRecords(t *testing.T) {
|
|
e := newEngine(t)
|
|
fw := &fakeFirewall{}
|
|
e.Firewall = fw
|
|
id := addWAN(t, e)
|
|
z := e.State().ByID(model.KindZone, id).(*model.Zone)
|
|
if strings.Join(z.Interfaces, ",") != "wan,ppp*" {
|
|
t.Errorf("interfaces not normalized: %q", z.Interfaces)
|
|
}
|
|
if len(fw.applied) != 1 || len(fw.applied[0].Zones) != 1 {
|
|
t.Errorf("firewall got %+v", fw.applied)
|
|
}
|
|
h, _ := e.Store().ListHistory(context.Background(), 10, 0)
|
|
if len(h) != 1 || h[0].Description != "create zone wan" || h[0].Via != "cli" || h[0].Username != "admin" {
|
|
t.Errorf("history %+v", h[0])
|
|
}
|
|
// Nothing changed: nothing recorded or applied.
|
|
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, _ func() int64) error { return nil })
|
|
if err != nil || res.Applied || len(res.Changes) != 0 || len(fw.applied) != 1 {
|
|
t.Errorf("empty change: %+v %v", res, err)
|
|
}
|
|
}
|
|
|
|
func TestValidationAndFirewallRefusals(t *testing.T) {
|
|
e := newEngine(t)
|
|
fw := &fakeFirewall{}
|
|
e.Firewall = fw
|
|
addWAN(t, e)
|
|
addRule := func(r *model.Rule) (*Result, error) {
|
|
return e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
|
r.ID = newID()
|
|
st.Rules = append(st.Rules, r)
|
|
return nil
|
|
})
|
|
}
|
|
// Validation: an unknown set.
|
|
_, err := addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Match: model.Match{Sources: []string{"trusted"}}})
|
|
if ee, ok := AsError(err); !ok || ee.Kind != KindValidation || ee.Problems[0].Code != "unknown_set" {
|
|
t.Errorf("unknown set: %v", err)
|
|
}
|
|
// nftables refuses: nothing applied or saved.
|
|
fw.checkErr = errors.New("syntax error")
|
|
_, err = addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Raw: "bogus"})
|
|
if ee, ok := AsError(err); !ok || ee.Kind != KindFirewall {
|
|
t.Errorf("refused ruleset: %v", err)
|
|
}
|
|
// Applying fails: nothing saved.
|
|
fw.checkErr, fw.applyErr = nil, errors.New("netlink: busy")
|
|
_, err = addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Match: model.Match{Services: []string{"ssh"}}})
|
|
if ee, ok := AsError(err); !ok || ee.Kind != KindFirewall || !strings.Contains(ee.Message, "nothing was changed") {
|
|
t.Errorf("failed apply: %v", err)
|
|
}
|
|
if len(e.State().Rules) != 0 {
|
|
t.Errorf("rules saved after failures: %+v", e.State().Rules)
|
|
}
|
|
// A dry run is checked, but neither applied nor saved.
|
|
fw.applyErr = nil
|
|
before := len(fw.applied)
|
|
res, err := e.Apply(context.Background(), admin, "", Options{DryRun: true}, func(st *model.State, newID func() int64) error {
|
|
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: 1, Action: model.ActionDrop})
|
|
return nil
|
|
})
|
|
if err != nil || res.Applied || len(res.Changes) != 1 || len(fw.applied) != before {
|
|
t.Errorf("dry run: %+v %v", res, err)
|
|
}
|
|
}
|
|
|
|
func TestRevertAppliesThePreviousRuleset(t *testing.T) {
|
|
e := newEngine(t)
|
|
fw := &fakeFirewall{}
|
|
e.Firewall = fw
|
|
addWAN(t, e)
|
|
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
|
st.Settings.ForwardPolicy = model.PolicyAccept
|
|
return nil
|
|
})
|
|
if err != nil || !res.Applied {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := e.Revert(context.Background(), admin, res.HistoryID, Options{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last := fw.applied[len(fw.applied)-1]
|
|
if last.Settings.ForwardPolicy != model.PolicyDrop || e.State().Settings.ForwardPolicy != model.PolicyDrop {
|
|
t.Errorf("after revert: applied %s, state %s", last.Settings.ForwardPolicy, e.State().Settings.ForwardPolicy)
|
|
}
|
|
// Undoing it twice is refused.
|
|
if _, err := e.Revert(context.Background(), admin, res.HistoryID, Options{}); err == nil {
|
|
t.Error("second undo accepted")
|
|
}
|
|
}
|
|
|
|
func TestPositionsStayStableUntilCrowded(t *testing.T) {
|
|
e := newEngine(t)
|
|
for i := 0; i < 3; i++ {
|
|
e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
|
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: float64(i + 1), Action: model.ActionAccept,
|
|
Match: model.Match{Services: []string{"ssh"}}})
|
|
return nil
|
|
})
|
|
}
|
|
// Inserting between two rules changes only the new one.
|
|
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
|
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: 1.5, Action: model.ActionDrop})
|
|
return nil
|
|
})
|
|
if err != nil || len(res.Changes) != 1 {
|
|
t.Fatalf("insert: %+v %v", res, err)
|
|
}
|
|
var order []string
|
|
for _, r := range e.State().Rules {
|
|
order = append(order, r.Action)
|
|
}
|
|
if strings.Join(order, ",") != "accept,drop,accept,accept" {
|
|
t.Errorf("order %v", order)
|
|
}
|
|
}
|
|
|
|
// TestQuotaRestartsWarns: a change that counts a quota so what it used
|
|
// can't carry over warns; one that can (to a total, wider groups) doesn't.
|
|
func TestQuotaRestartsWarns(t *testing.T) {
|
|
e := newEngine(t)
|
|
ctx := context.Background()
|
|
var id int64
|
|
if _, err := e.Apply(ctx, admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
|
id = newID()
|
|
st.Rules = append(st.Rules, &model.Rule{ID: id, Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 1 << 30, Over: true, PerSource: true}})
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
change := func(f func(r *model.Rule)) []string {
|
|
res, err := e.Apply(ctx, admin, "", Options{DryRun: true}, func(st *model.State, _ func() int64) error {
|
|
f(st.ByID(model.KindRule, id).(*model.Rule))
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var codes []string
|
|
for _, w := range res.Warnings {
|
|
if w.Code == "quota_restarts" {
|
|
codes = append(codes, w.Message)
|
|
}
|
|
}
|
|
return codes
|
|
}
|
|
if w := change(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }); len(w) != 1 || !strings.Contains(w[0], "per source can't be counted per destination") {
|
|
t.Errorf("per source to per destination: %v", w)
|
|
}
|
|
if w := change(func(r *model.Rule) { r.Quota.PerSource = false }); len(w) != 0 {
|
|
t.Errorf("to a total (summed): %v", w)
|
|
}
|
|
if w := change(func(r *model.Rule) { r.GroupV6 = 56 }); len(w) != 0 {
|
|
t.Errorf("wider groups: %v", w)
|
|
}
|
|
if w := change(func(r *model.Rule) { r.GroupV6 = 128 }); len(w) != 1 {
|
|
t.Errorf("finer groups: %v", w)
|
|
}
|
|
if w := change(func(r *model.Rule) { r.Quota.Bytes = 2 << 30 }); len(w) != 0 {
|
|
t.Errorf("another amount: %v", w)
|
|
}
|
|
}
|