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