Add changesets, bandwidth limits and quotas; generic examples

Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 11:18:54 +01:00
1 parent ebec2cfcca
commit 2880f7ddc9
63 files changed
+4018 -385

No files matched your search

+468
View File
@@ -0,0 +1,468 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
"housecarl/internal/store"
)
// Changesets: changes staged to be reviewed (validation, nftables' check,
// the ruleset's diff) and applied together, as one history entry, or
// discarded. The web UI stages its edits by default; the CLI and the API
// stage when a changeset is named (?changeset=ID, X-Housecarl-Changeset).
// changesetParam is the changeset a request names (0: none).
func changesetParam(r *http.Request) (int64, error) {
v := r.URL.Query().Get("changeset")
if v == "" {
v = r.Header.Get("X-Housecarl-Changeset")
}
if v == "" {
return 0, nil
}
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
return 0, badRequest("invalid changeset %q", v)
}
return id, nil
}
// notStaged refuses a changeset on a change that can't be staged (undo,
// import, takeover): it would otherwise be applied at once, unexpectedly.
func notStaged(r *http.Request, what string) error {
if id, err := changesetParam(r); err != nil || id == 0 {
return err
}
return badRequest("%s can't be staged in a changeset; leave out --changeset (or unset HOUSECARL_CHANGESET) to do it now", what)
}
// openChangeset loads a changeset to change: it must be open, and the
// caller's own unless they are an administrator.
func (s *Server) openChangeset(r *http.Request, p *auth.Principal, id int64) (*model.Changeset, error) {
cs, err := s.store.GetChangeset(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return nil, notFound("there is no changeset %d", id)
}
if err != nil {
return nil, err
}
if cs.Status != model.ChangesetOpen {
return nil, conflict("changeset %d is %s", id, cs.Status)
}
if !ownsChangeset(p, cs) && !p.IsAdmin() {
return nil, forbidden("changeset %d is %s's, not yours", id, cs.Username)
}
return cs, nil
}
func ownsChangeset(p *auth.Principal, cs *model.Changeset) bool {
if p == nil || p.User == nil {
return false
}
if p.User.ID != 0 {
return cs.UserID == p.User.ID
}
return cs.UserID == 0 && cs.Username == p.User.Username
}
// stage adds an operation to a changeset. The changeset with it must
// still be valid and pass nftables' check; nothing is applied.
func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal, id int64, op model.Op) error {
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
// The operation on its own, after the changeset so far: what it
// changes, for its summary.
before := s.eng.State()
if err := replay(before, cs.Ops); err != nil {
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
}
after := before.Clone()
objID, err := applyOp(after, &op)
if err != nil {
return err
}
engine.Normalize(after)
after.Sort()
changes := engine.Diff(before, after)
if len(changes) == 0 {
writeJSON(w, http.StatusOK, api.MutationResponse{Changes: []model.Change{}, Changeset: cs})
return nil
}
op.Summary = engine.Describe(changes)
op.AddedAt = s.nowFn()
for _, o := range cs.Ops {
if o.Seq >= op.Seq {
op.Seq = o.Seq + 1
}
}
if op.Seq == 0 {
op.Seq = 1
}
ops := append(append([]model.Op{}, cs.Ops...), op)
dry := queryBool(r, "dry_run")
// The whole changeset, checked as it would be applied.
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
return replay(st, ops)
})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
if !dry {
cs.Ops = ops
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
}
out := api.MutationResponse{Staged: !dry, DryRun: dry, Changes: changes, Warnings: res.Warnings, Changeset: cs}
if object := objectOf(&op, objID); object != nil {
if v := object(after); v != nil {
out.Object, _ = json.Marshal(v)
}
}
if dry {
out.Diff = s.rulesetDiff(res.Before, res.After)
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
var in api.ChangesetInput
if r.ContentLength != 0 {
if err := decodeJSON(r, &in); err != nil {
return err
}
}
cs := &model.Changeset{Username: p.Name(), Via: via(r, p), Description: strings.TrimSpace(in.Description), Status: model.ChangesetOpen}
if p.User != nil {
cs.UserID = p.User.ID
}
if err := s.store.CreateChangeset(r.Context(), cs); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "changeset.create", fmt.Sprintf("changeset %d", cs.ID), "success", cs.Description)
writeJSON(w, http.StatusCreated, cs)
return nil
}
func (s *Server) listChangesets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
var uid int64
if queryBool(r, "mine") {
uid = -1
if p.User != nil && p.User.ID != 0 {
uid = p.User.ID
}
}
list, err := s.store.ListChangesets(r.Context(), r.URL.Query().Get("status"), max(uid, 0), queryInt(r, "limit", 100))
if err != nil {
return err
}
if uid == -1 { // the socket's users have no user ID: by name
mine := []*model.Changeset{}
for _, cs := range list {
if ownsChangeset(p, cs) {
mine = append(mine, cs)
}
}
list = mine
}
writeJSON(w, http.StatusOK, list)
return nil
}
func (s *Server) getChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
cs, err := s.store.GetChangeset(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("there is no changeset %d", id)
}
if err != nil {
return err
}
writeJSON(w, http.StatusOK, cs)
return nil
}
func (s *Server) updateChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.ChangesetInput
if err := decodeJSON(r, &in); err != nil {
return err
}
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
cs.Description = strings.TrimSpace(in.Description)
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
writeJSON(w, http.StatusOK, cs)
return nil
}
// removeOp takes one operation out of a changeset. Later ones that
// needed it show up in the preview.
func (s *Server) removeOp(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
seq, err := strconv.Atoi(r.PathValue("seq"))
if err != nil {
return badRequest("invalid operation %q", r.PathValue("seq"))
}
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
kept := []model.Op{}
for _, o := range cs.Ops {
if o.Seq != seq {
kept = append(kept, o)
}
}
if len(kept) == len(cs.Ops) {
return notFound("changeset %d has no operation %d", id, seq)
}
cs.Ops = kept
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
writeJSON(w, http.StatusOK, cs)
return nil
}
// previewChangeset says what applying a changeset would do, without
// doing it: its changes, problems, nftables' check, the ruleset's diff.
func (s *Server) previewChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
cs, err := s.store.GetChangeset(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("there is no changeset %d", id)
}
if err != nil {
return err
}
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
return replay(st, cs.Ops)
})
out := api.ChangesetPreview{Valid: err == nil && res != nil && len(res.Changes) > 0, Changes: []model.Change{}}
if res != nil {
if res.Changes != nil {
out.Changes = res.Changes
}
out.Warnings = res.Warnings
}
if err != nil {
out.Error = errorInfo(err)
} else if res != nil {
out.Diff = s.rulesetDiff(res.Before, res.After)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// errorInfo is an error as the API reports it.
func errorInfo(err error) *api.ErrorInfo {
var oe *opError
if errors.As(err, &oe) {
info, _ := oe.info()
return &info
}
var he *httpError
if errors.As(err, &he) {
return &api.ErrorInfo{Code: he.code, Message: he.msg}
}
if ee, ok := engine.AsError(err); ok {
return &api.ErrorInfo{Code: ee.Kind, Message: ee.Message, Problems: ee.Problems}
}
return &api.ErrorInfo{Code: "internal", Message: err.Error()}
}
// applyChangeset applies a changeset's operations as one change: one
// history entry, and (over the network) on probation like any change.
func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.ChangesetInput
if r.ContentLength != 0 {
if err := decodeJSON(r, &in); err != nil {
return err
}
}
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
if d := strings.TrimSpace(in.Description); d != "" {
cs.Description = d
}
if len(cs.Ops) == 0 {
return badRequest("changeset %d has no changes to apply", id)
}
res, err := s.eng.Apply(r.Context(), p, cs.Description, engine.Options{Via: via(r, p)}, func(st *model.State, _ func() int64) error {
return replay(st, cs.Ops)
})
if err != nil {
s.eng.Audit(r.Context(), p, "changeset.apply", fmt.Sprintf("changeset %d", id), "failure", err.Error())
s.writeErr(w, r, err, res)
return nil
}
cs.Status, cs.HistoryID = model.ChangesetApplied, res.HistoryID
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "changeset.apply", fmt.Sprintf("changeset %d", id), "success", fmt.Sprintf("history %d", res.HistoryID))
s.afterChange(r.Context(), p, res)
out := s.mutation(res, nil)
out.Changeset = cs
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) discardChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
}
s.csMu.Lock()
defer s.csMu.Unlock()
cs, err := s.openChangeset(r, p, id)
if err != nil {
return err
}
cs.Status = model.ChangesetDiscarded
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "changeset.discard", fmt.Sprintf("changeset %d", id), "success", fmt.Sprintf("%d operations", len(cs.Ops)))
writeJSON(w, http.StatusOK, cs)
return nil
}
// ---- reading the configuration as a changeset will make it
// viewState is the configuration a read shows: the current one, or with
// a changeset named, as it will be once that is applied (draft true).
// A changeset that no longer applies shows the current configuration,
// with the reason in the X-Housecarl-Changeset-Error header.
func (s *Server) viewState(w http.ResponseWriter, r *http.Request) (cur, view *model.State, draft bool, err error) {
cur = s.eng.State()
id, err := changesetParam(r)
if err != nil || id == 0 {
return cur, cur, false, err
}
cs, err := s.store.GetChangeset(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return nil, nil, false, notFound("there is no changeset %d", id)
}
if err != nil {
return nil, nil, false, err
}
if cs.Status != model.ChangesetOpen {
return cur, cur, false, nil
}
next := cur.Clone()
if err := replay(next, cs.Ops); err != nil {
w.Header().Set("X-Housecarl-Changeset-Error", err.Error())
return cur, cur, false, nil
}
engine.Normalize(next)
next.Sort()
return cur, next, true, nil
}
// withPending lists a kind's objects as a changeset will leave them, each
// marked "pending": "new", "changed" or "deleted" (deleted ones stay in
// the list, in their place).
func withPending(cur, next *model.State, kind string) []any {
was := map[int64]string{}
for _, o := range cur.Objects(kind) {
b, _ := json.Marshal(o)
was[o.GetID()] = string(b)
}
// The deleted ones go back in, so they sort into their places.
merged := next.Clone()
deleted := map[int64]bool{}
for _, o := range cur.Objects(kind) {
if merged.ByID(kind, o.GetID()) == nil {
b, _ := json.Marshal(o)
merged.Put(kind, o.GetID(), b)
deleted[o.GetID()] = true
}
}
merged.Sort()
out := []any{}
for _, o := range merged.Objects(kind) {
var m map[string]any
b, _ := json.Marshal(o)
json.Unmarshal(b, &m)
old, existed := was[o.GetID()]
switch {
case deleted[o.GetID()]:
m["pending"] = "deleted"
case !existed:
m["pending"] = "new"
case old != string(b):
m["pending"] = "changed"
}
out = append(out, m)
}
return out
}
+113 -183
View File
@@ -7,6 +7,8 @@ import (
"sort"
"strings"
udiff "github.com/aymanbagabas/go-udiff"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
@@ -14,26 +16,46 @@ import (
"housecarl/internal/store"
)
// change applies an edit through the engine and answers it; object picks
// what to return from the new state. A network change starts (or joins)
// the probation.
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, edit engine.Edit, object func(st *model.State) any) error {
// change makes an operation: at once through the engine, or, with a
// changeset named (?changeset=ID or X-Housecarl-Changeset), staged in it.
// A network change starts (or joins) the probation.
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, op model.Op) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
res, err := s.eng.Apply(r.Context(), p, description, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, edit)
if op.Op == model.OpCreate && op.ID == 0 {
ids, err := s.store.AllocateIDs(r.Context(), 1)
if err != nil {
return err
}
op.ID = ids[0]
}
csID, err := changesetParam(r)
if err != nil {
return err
}
if csID != 0 {
return s.stage(w, r, p, csID, op)
}
var id int64
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, func(st *model.State, _ func() int64) error {
var err error
id, err = applyOp(st, &op)
return err
})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
s.afterChange(r.Context(), p, res)
// POST to a collection creates; POST to an object (move) changes it.
s.respondChange(w, r, res, object, r.Method == http.MethodPost && r.PathValue("ref") == "")
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), s.mutation(res, objectOf(&op, id)))
return nil
}
// respondChange answers a change: 201 when it created something.
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
// mutation is the answer to a change: what changed, warnings, the object
// (as it is after the change), the probation, and for a dry run the
// ruleset's diff.
func (s *Server) mutation(res *engine.Result, object func(st *model.State) any) api.MutationResponse {
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
if out.Changes == nil {
out.Changes = []model.Change{}
@@ -41,16 +63,37 @@ func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engi
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
out.ConfirmBy = &pr.Deadline
}
if object != nil && (res.Applied || res.DryRun) {
if v := object(s.eng.State()); v != nil {
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
if v := object(res.After); v != nil {
out.Object, _ = json.Marshal(v)
}
}
status := http.StatusOK
if res.Applied && creates {
status = http.StatusCreated
if res.DryRun && len(res.Changes) > 0 {
out.Diff = s.rulesetDiff(res.Before, res.After)
}
writeJSON(w, status, out)
return out
}
// mutationStatus is 201 for an applied create, else 200.
func (s *Server) mutationStatus(res *engine.Result, creates bool) int {
if res.Applied && creates {
return http.StatusCreated
}
return http.StatusOK
}
// respondChange answers a change made without an operation (undo).
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
writeJSON(w, s.mutationStatus(res, creates), s.mutation(res, object))
}
// rulesetDiff is the unified diff of the rulesets two configurations
// make: the file housecarl-ruleset.service loads, now and after.
func (s *Server) rulesetDiff(before, after *model.State) string {
if before == nil || after == nil {
return ""
}
return udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", s.fw.Render(before).Text, s.fw.Render(after).Text)
}
// kindOf maps the API's plural path names to kinds.
@@ -84,16 +127,29 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
if err != nil {
return err
}
st := s.eng.State()
cur, st, draft, err := s.viewState(w, r)
if err != nil {
return err
}
out := []any{}
q := r.URL.Query()
for _, o := range st.Objects(kind) {
if c := q.Get("chain"); c != "" {
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
c := r.URL.Query().Get("chain")
if draft {
// Marked new, changed or deleted.
for _, o := range withPending(cur, st, kind) {
if c != "" && kind == model.KindRule && o.(map[string]any)["chain"] != c {
continue
}
out = append(out, o)
}
} else {
for _, o := range st.Objects(kind) {
if c != "" {
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
continue
}
}
out = append(out, o)
}
out = append(out, o)
}
// Services: the built-in ones too, unless overridden.
if kind == model.KindService && !queryBool(r, "own") {
@@ -102,9 +158,14 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
out = append(out, b)
}
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].(*model.Service).Name < out[j].(*model.Service).Name
})
name := func(x any) string {
if m, ok := x.(map[string]any); ok {
n, _ := m["name"].(string)
return n
}
return x.(*model.Service).Name
}
sort.SliceStable(out, func(i, j int) bool { return name(out[i]) < name(out[j]) })
}
writeJSON(w, http.StatusOK, out)
return nil
@@ -118,7 +179,10 @@ func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Princ
if err != nil {
return err
}
st := s.eng.State()
_, st, _, err := s.viewState(w, r)
if err != nil {
return err
}
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil && kind == model.KindService {
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
@@ -235,29 +299,11 @@ func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
if err := decodeJSON(r, &raw); err != nil {
return err
}
o, err := decodeObject(kind, raw)
if err != nil {
// Refuse a bad object now, not when it is applied.
if _, err := decodeObject(kind, raw); err != nil {
return err
}
if svc, ok := o.(*model.Service); ok {
svc.Builtin = false
}
if rule, ok := o.(*model.Rule); ok && rule.Action == "" {
rule.Action = model.ActionAccept
}
if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 {
f.Protocols = []string{"tcp"}
}
var id int64
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
id = newID()
setID(o, id)
if pos := position(o); pos != nil && *pos == 0 {
*pos = lastPosition(st, kind, chainOf(o))
}
data, _ := json.Marshal(o)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw})
}
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
@@ -269,34 +315,8 @@ func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
if err := decodeJSON(r, &patch); err != nil {
return err
}
delete(patch, "id")
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
id = o.GetID()
// The patch's fields over the object's.
var cur map[string]json.RawMessage
b, _ := json.Marshal(o)
json.Unmarshal(b, &cur)
for k, v := range patch {
if string(v) == "null" {
delete(cur, k)
} else {
cur[k] = v
}
}
merged, _ := json.Marshal(cur)
next, err := decodeObject(kind, merged)
if err != nil {
return err
}
setID(next, id)
data, _ := json.Marshal(next)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
data, _ := json.Marshal(patch)
return s.change(w, r, p, model.Op{Op: model.OpUpdate, Kind: kind, Ref: r.PathValue("ref"), Data: data})
}
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
@@ -304,16 +324,7 @@ func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
if err != nil {
return err
}
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
if used := usedBy(st, kind, o); used != "" {
return conflict("%s %s is used by %s; change that first", kind, o.Label(), used)
}
return st.Put(kind, o.GetID(), nil)
}, nil)
return s.change(w, r, p, model.Op{Op: model.OpDelete, Kind: kind, Ref: r.PathValue("ref")})
}
// usedBy names an object that refers to o (zones, sets and services are
@@ -364,8 +375,7 @@ func usedBy(st *model.State, kind string, o model.Object) string {
return ""
}
// moveObject reorders a rule or NAT rule: before or after another, or to
// the start or end; a rule can change chain at the same time.
// moveObject reorders a rule or NAT rule (moveOp).
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r)
if err != nil {
@@ -375,72 +385,8 @@ func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err := decodeJSON(r, &in); err != nil {
return err
}
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
id = o.GetID()
pos := position(o)
if pos == nil {
return badRequest("only rules, port forwards, source NAT rules and redirects have an order")
}
if rule, ok := o.(*model.Rule); ok && in.Chain != "" {
rule.Chain = in.Chain
}
chain := chainOf(o)
// The others in order.
var others []model.Object
for _, x := range st.Objects(kind) {
if x.GetID() != id && chainOf(x) == chain {
others = append(others, x)
}
}
sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) })
place := func(i int) { // before others[i] (i == len: at the end)
switch {
case len(others) == 0:
*pos = 1
case i == 0:
*pos = *position(others[0]) - 1
case i == len(others):
*pos = *position(others[len(others)-1]) + 1
default:
*pos = (*position(others[i-1]) + *position(others[i])) / 2
}
}
index := func(ref int64) (int, error) {
for i, x := range others {
if x.GetID() == ref {
return i, nil
}
}
return 0, badRequest("%d isn't another %s of the same chain", ref, kind)
}
switch {
case in.Before != 0:
i, err := index(in.Before)
if err != nil {
return err
}
place(i)
case in.After != 0:
i, err := index(in.After)
if err != nil {
return err
}
place(i + 1)
case in.To == "start":
place(0)
case in.To == "end" || in.Chain != "":
place(len(others))
default:
return badRequest("say where: before, after, or to start or end")
}
data, _ := json.Marshal(o)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
data, _ := json.Marshal(in)
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
}
// ---- settings
@@ -449,7 +395,11 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Pri
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
writeJSON(w, http.StatusOK, s.eng.State().Settings)
_, st, _, err := s.viewState(w, r)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, st.Settings)
return nil
}
@@ -458,35 +408,8 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.
if err := decodeJSON(r, &patch); err != nil {
return err
}
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
var cur map[string]json.RawMessage
b, _ := json.Marshal(st.Settings)
json.Unmarshal(b, &cur)
for k, v := range patch {
// Built-ins merge one by one.
if k == "builtins" {
var cb, pb map[string]json.RawMessage
json.Unmarshal(cur[k], &cb)
if err := json.Unmarshal(v, &pb); err != nil {
return badRequest("builtins: %v", err)
}
for bk, bv := range pb {
cb[bk] = bv
}
v, _ = json.Marshal(cb)
}
cur[k] = v
}
merged, _ := json.Marshal(cur)
var next model.Settings
dec := json.NewDecoder(strings.NewReader(string(merged)))
dec.DisallowUnknownFields()
if err := dec.Decode(&next); err != nil {
return badRequest("settings: %v", err)
}
st.Settings = next
return nil
}, func(st *model.State) any { return st.Settings })
data, _ := json.Marshal(patch)
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
}
// ---- the ruleset
@@ -495,7 +418,11 @@ func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Princip
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
rs := s.fw.Render(s.eng.State())
_, st, _, err := s.viewState(w, r)
if err != nil {
return err
}
rs := s.fw.Render(st)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(rs.Text))
return nil
@@ -538,6 +465,9 @@ func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.P
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := notStaged(r, "an undo"); err != nil {
return err
}
id, err := pathID(r, "id")
if err != nil {
return err
+6
View File
@@ -18,6 +18,9 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := notStaged(r, "an import"); err != nil {
return err
}
var in api.ImportInput
if err := decodeJSON(r, &in); err != nil {
return err
@@ -105,6 +108,9 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
if err := requireAdmin(p); err != nil {
return err
}
if err := notStaged(r, "a takeover"); err != nil {
return err
}
var in api.TakeoverInput
if err := decodeJSON(r, &in); err != nil {
return err
+265
View File
@@ -0,0 +1,265 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"housecarl/internal/api"
"housecarl/internal/engine"
"housecarl/internal/model"
)
// Every configuration change is an operation (model.Op): applied at once,
// or staged in a changeset and replayed when the changeset is applied.
// applyOp is the one place an operation becomes an edit.
// applyOp makes an operation's change to st. It returns the ID of the
// object it made or changed (0 for deletes and settings).
func applyOp(st *model.State, op *model.Op) (int64, error) {
switch op.Op {
case model.OpCreate:
return createOp(st, op)
case model.OpUpdate:
return updateOp(st, op)
case model.OpDelete:
return 0, deleteOp(st, op)
case model.OpMove:
return moveOp(st, op)
case model.OpSettings:
return 0, settingsOp(st, op)
}
return 0, badRequest("unknown operation %q", op.Op)
}
func createOp(st *model.State, op *model.Op) (int64, error) {
o, err := decodeObject(op.Kind, op.Data)
if err != nil {
return 0, err
}
if svc, ok := o.(*model.Service); ok {
svc.Builtin = false
}
if rule, ok := o.(*model.Rule); ok && rule.Action == "" {
rule.Action = model.ActionAccept
}
if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 {
f.Protocols = []string{"tcp"}
}
if st.ByID(op.Kind, op.ID) != nil {
return 0, conflict("%s %d exists already", op.Kind, op.ID)
}
setID(o, op.ID)
if pos := position(o); pos != nil && *pos == 0 {
*pos = lastPosition(st, op.Kind, chainOf(o))
}
data, _ := json.Marshal(o)
return op.ID, st.Put(op.Kind, op.ID, data)
}
func updateOp(st *model.State, op *model.Op) (int64, error) {
var patch map[string]json.RawMessage
if err := json.Unmarshal(op.Data, &patch); err != nil {
return 0, badRequest("%s: %v", op.Kind, err)
}
delete(patch, "id")
o, err := findObject(st, op.Kind, op.Ref)
if err != nil {
return 0, err
}
id := o.GetID()
// The patch's fields over the object's.
var cur map[string]json.RawMessage
b, _ := json.Marshal(o)
json.Unmarshal(b, &cur)
for k, v := range patch {
if string(v) == "null" {
delete(cur, k)
} else {
cur[k] = v
}
}
merged, _ := json.Marshal(cur)
next, err := decodeObject(op.Kind, merged)
if err != nil {
return 0, err
}
setID(next, id)
data, _ := json.Marshal(next)
return id, st.Put(op.Kind, id, data)
}
func deleteOp(st *model.State, op *model.Op) error {
o, err := findObject(st, op.Kind, op.Ref)
if err != nil {
return err
}
if used := usedBy(st, op.Kind, o); used != "" {
return conflict("%s %s is used by %s; change that first", op.Kind, o.Label(), used)
}
return st.Put(op.Kind, o.GetID(), nil)
}
// moveOp reorders a rule or NAT rule: before or after another, or to the
// start or end; a rule can change chain at the same time.
func moveOp(st *model.State, op *model.Op) (int64, error) {
var in api.MoveInput
if err := json.Unmarshal(op.Data, &in); err != nil {
return 0, badRequest("move: %v", err)
}
o, err := findObject(st, op.Kind, op.Ref)
if err != nil {
return 0, err
}
id := o.GetID()
pos := position(o)
if pos == nil {
return 0, badRequest("only rules, port forwards, source NAT rules and redirects have an order")
}
if rule, ok := o.(*model.Rule); ok && in.Chain != "" {
rule.Chain = in.Chain
}
chain := chainOf(o)
// The others in order.
var others []model.Object
for _, x := range st.Objects(op.Kind) {
if x.GetID() != id && chainOf(x) == chain {
others = append(others, x)
}
}
sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) })
place := func(i int) { // before others[i] (i == len: at the end)
switch {
case len(others) == 0:
*pos = 1
case i == 0:
*pos = *position(others[0]) - 1
case i == len(others):
*pos = *position(others[len(others)-1]) + 1
default:
*pos = (*position(others[i-1]) + *position(others[i])) / 2
}
}
index := func(ref int64) (int, error) {
for i, x := range others {
if x.GetID() == ref {
return i, nil
}
}
return 0, badRequest("%d isn't another %s of the same chain", ref, op.Kind)
}
switch {
case in.Before != 0:
i, err := index(in.Before)
if err != nil {
return 0, err
}
place(i)
case in.After != 0:
i, err := index(in.After)
if err != nil {
return 0, err
}
place(i + 1)
case in.To == "start":
place(0)
case in.To == "end" || in.Chain != "":
place(len(others))
default:
return 0, badRequest("say where: before, after, or to start or end")
}
data, _ := json.Marshal(o)
return id, st.Put(op.Kind, id, data)
}
func settingsOp(st *model.State, op *model.Op) error {
var patch map[string]json.RawMessage
if err := json.Unmarshal(op.Data, &patch); err != nil {
return badRequest("settings: %v", err)
}
var cur map[string]json.RawMessage
b, _ := json.Marshal(st.Settings)
json.Unmarshal(b, &cur)
for k, v := range patch {
// Built-ins merge one by one.
if k == "builtins" {
var cb, pb map[string]json.RawMessage
json.Unmarshal(cur[k], &cb)
if err := json.Unmarshal(v, &pb); err != nil {
return badRequest("builtins: %v", err)
}
for bk, bv := range pb {
cb[bk] = bv
}
v, _ = json.Marshal(cb)
}
cur[k] = v
}
merged, _ := json.Marshal(cur)
var next model.Settings
dec := json.NewDecoder(strings.NewReader(string(merged)))
dec.DisallowUnknownFields()
if err := dec.Decode(&next); err != nil {
return badRequest("settings: %v", err)
}
st.Settings = next
return nil
}
// replay applies a changeset's operations to st, in order. An operation
// that fails is named in the error (opError).
func replay(st *model.State, ops []model.Op) error {
for i := range ops {
if _, err := applyOp(st, &ops[i]); err != nil {
return &opError{seq: ops[i].Seq, summary: ops[i].Summary, err: err}
}
}
return nil
}
// opError: an operation of a changeset that can't be applied (the
// objects it names changed since it was staged, say).
type opError struct {
seq int
summary string
err error
}
func (e *opError) Error() string {
return fmt.Sprintf("operation %d (%s) can't be applied: %v", e.seq, e.summary, e.err)
}
func (e *opError) Unwrap() error { return e.err }
// info is the error for the API, with the inner error's status.
func (e *opError) info() (api.ErrorInfo, int) {
info := api.ErrorInfo{Code: "conflict", Message: e.Error(), Op: e.seq}
status := http.StatusConflict
var he *httpError
if errors.As(e.err, &he) {
info.Code, status = he.code, he.status
}
if ee, ok := engine.AsError(e.err); ok {
info.Code, info.Problems, status = ee.Kind, ee.Problems, engineStatus(ee.Kind)
}
return info, status
}
// objectOf picks what an operation's answer returns from a state.
func objectOf(op *model.Op, id int64) func(st *model.State) any {
switch op.Op {
case model.OpDelete:
return nil
case model.OpSettings:
return func(st *model.State) any { return st.Settings }
}
return func(st *model.State) any {
if o := st.ByID(op.Kind, id); o != nil {
return o
}
return nil
}
}
+263
View File
@@ -0,0 +1,263 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/nft"
"housecarl/internal/render"
)
// Quotas: nftables counts them, and never resets them. housecarld resets
// each at the start of its period (daily, weekly, monthly), or when asked,
// and saves what they used every few minutes and when it stops, so a
// reboot (whose boot ruleset starts them at zero) doesn't lose it.
const (
quotaResetsKey = "quota_resets" // rule mark → when its quota was last reset
QuotaUsageKey = "quota_usage" // nft.QuotaUsage, saved
)
// periodStart is when the current period of a reset began (zero for a
// quota reset only by hand), in local time.
func periodStart(now time.Time, reset string, day int) time.Time {
y, m, d := now.Date()
midnight := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
switch reset {
case "daily":
return midnight
case "weekly":
back := (int(now.Weekday()) + 6) % 7 // Monday
return midnight.AddDate(0, 0, -back)
case "monthly":
if day < 1 {
day = 1
}
start := monthDay(y, m, day, now.Location())
if start.After(now) {
py, pm, _ := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0).Date()
start = monthDay(py, pm, day, now.Location())
}
return start
}
return time.Time{}
}
// monthDay is midnight on a day of a month, or on its last day when the
// month is shorter.
func monthDay(y int, m time.Month, day int, loc *time.Location) time.Time {
last := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if day > last {
day = last
}
return time.Date(y, m, day, 0, 0, 0, 0, loc)
}
// nextReset is when the period after the current one starts.
func nextReset(now time.Time, reset string, day int) time.Time {
start := periodStart(now, reset, day)
switch reset {
case "daily":
return start.AddDate(0, 0, 1)
case "weekly":
return start.AddDate(0, 0, 7)
case "monthly":
y, m, _ := start.Date()
ny, nm, _ := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0).Date()
if day < 1 {
day = 1
}
return monthDay(ny, nm, day, now.Location())
}
return time.Time{}
}
func (s *Server) quotaResets(ctx context.Context) map[string]time.Time {
out := map[string]time.Time{}
if v, err := s.store.GetMeta(ctx, quotaResetsKey); err == nil && v != "" {
json.Unmarshal([]byte(v), &out)
}
return out
}
func (s *Server) saveQuotaResets(ctx context.Context, m map[string]time.Time) {
b, _ := json.Marshal(m)
if err := s.store.SetMeta(ctx, quotaResetsKey, string(b)); err != nil {
s.log.Error("saving the quotas' reset times failed", "err", err)
}
}
// RunQuotas resets quotas when their period ends and saves their usage,
// until ctx ends (then it saves once more).
func (s *Server) RunQuotas(ctx context.Context) {
s.checkQuotas(ctx)
tick := time.NewTicker(time.Minute)
defer tick.Stop()
n := 0
for {
select {
case <-ctx.Done():
s.SaveQuotaUsage(context.Background())
return
case <-tick.C:
s.checkQuotas(ctx)
if n++; n%5 == 0 {
s.SaveQuotaUsage(ctx)
}
}
}
}
// checkQuotas resets the quotas whose period has ended since their last
// reset. A quota seen for the first time starts its period now.
func (s *Server) checkQuotas(ctx context.Context) {
st := s.eng.State()
resets := s.quotaResets(ctx)
now := s.nowFn()
changed := false
seen := map[string]bool{}
for _, r := range st.Rules {
if r.Quota == nil || r.Disabled || r.Raw != "" {
continue
}
key := fmt.Sprintf("r%d", r.ID)
seen[key] = true
last, ok := resets[key]
if !ok {
resets[key], changed = now, true
continue
}
start := periodStart(now, r.Quota.Reset, r.Quota.Day)
if start.IsZero() || !last.Before(start) {
continue
}
if err := s.fw.ResetQuota(ctx, r.ID, r.Quota.PerAddress() != ""); err != nil {
s.log.Error("resetting a quota failed", "rule", r.Label(), "err", err)
continue
}
s.log.Info("quota reset for its new period", "rule", r.Label(), "period", r.Quota.Reset)
resets[key], changed = now, true
s.forgetQuotaUsage(ctx, r.ID)
}
for key := range resets {
if !seen[key] {
delete(resets, key)
changed = true
}
}
if changed {
s.saveQuotaResets(ctx, resets)
}
}
// SaveQuotaUsage saves what the quotas used, for after a reboot.
func (s *Server) SaveQuotaUsage(ctx context.Context) {
t, err := s.fw.Table(ctx)
if err != nil {
return
}
b, _ := json.Marshal(t.QuotaUsage)
if err := s.store.SetMeta(ctx, QuotaUsageKey, string(b)); err != nil {
s.log.Error("saving the quotas' usage failed", "err", err)
}
}
// forgetQuotaUsage drops a reset quota from the saved usage, so a restart
// doesn't bring back what it used before.
func (s *Server) forgetQuotaUsage(ctx context.Context, ruleID int64) {
v, err := s.store.GetMeta(ctx, QuotaUsageKey)
if err != nil || v == "" {
return
}
var u nft.QuotaUsage
if json.Unmarshal([]byte(v), &u) != nil {
return
}
name := render.QuotaName(ruleID)
delete(u.Quotas, name)
delete(u.QuotaSets, name+"_v4")
delete(u.QuotaSets, name+"_v6")
b, _ := json.Marshal(u)
s.store.SetMeta(ctx, QuotaUsageKey, string(b))
}
// listQuotas answers what each rule's quota used.
func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
st := s.eng.State()
var usage nft.QuotaUsage
if t, err := s.fw.Table(r.Context()); err == nil {
usage = t.QuotaUsage
}
resets := s.quotaResets(r.Context())
now := s.nowFn()
out := []api.QuotaInfo{}
for _, rule := range st.Rules {
if rule.Quota == nil {
continue
}
q := api.QuotaInfo{RuleID: rule.ID, Rule: rule.Label(), Quota: *rule.Quota}
name := render.QuotaName(rule.ID)
if rule.Quota.PerAddress() == "" {
q.Used = usage.Quotas[name]
} else {
v4, v6 := rule.Groups()
for _, fam := range []string{"_v4", "_v6"} {
for _, e := range usage.QuotaSets[name+fam] {
addr := e.Addr
if fam == "_v4" && v4 < 32 {
addr = fmt.Sprintf("%s/%d", addr, v4)
} else if fam == "_v6" && v6 < 128 {
addr = fmt.Sprintf("%s/%d", addr, v6)
}
q.Addresses = append(q.Addresses, api.QuotaAddress{Address: addr, Used: e.Used})
q.Used += e.Used
}
}
sort.Slice(q.Addresses, func(i, j int) bool { return q.Addresses[i].Used > q.Addresses[j].Used })
}
if last, ok := resets[fmt.Sprintf("r%d", rule.ID)]; ok {
q.LastReset = &last
}
if next := nextReset(now, rule.Quota.Reset, rule.Quota.Day); !next.IsZero() {
q.NextReset = &next
}
out = append(out, q)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// resetQuota starts a rule's quota again now.
func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
o, err := findObject(s.eng.State(), model.KindRule, r.PathValue("ref"))
if err != nil {
return err
}
rule := o.(*model.Rule)
if rule.Quota == nil {
return badRequest("%s has no quota", rule.Label())
}
if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil {
return err
}
resets := s.quotaResets(r.Context())
resets[fmt.Sprintf("r%d", rule.ID)] = s.nowFn()
s.saveQuotaResets(r.Context(), resets)
s.forgetQuotaUsage(r.Context(), rule.ID)
s.eng.Audit(r.Context(), p, "quota.reset", rule.Label(), "success", "")
writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()})
return nil
}
+6
View File
@@ -70,6 +70,12 @@ func engineStatus(kind string) int {
// writeErr writes an error response. result, if given, is the preview of
// a refused change.
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
var oe *opError
if errors.As(err, &oe) {
info, status := oe.info()
writeJSON(w, status, api.ErrorBody{Error: info})
return
}
var he *httpError
if errors.As(err, &he) {
writeError(w, he.status, he.code, he.msg)
+10
View File
@@ -41,6 +41,16 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas))
m.HandleFunc("POST "+p+"/rules/{ref}/reset-quota", s.authed(s.resetQuota))
m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets))
m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset))
m.HandleFunc("GET "+p+"/changesets/{id}", s.authed(s.getChangeset))
m.HandleFunc("PATCH "+p+"/changesets/{id}", s.authed(s.updateChangeset))
m.HandleFunc("GET "+p+"/changesets/{id}/preview", s.authed(s.previewChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/apply", s.authed(s.applyChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/discard", s.authed(s.discardChangeset))
m.HandleFunc("DELETE "+p+"/changesets/{id}/ops/{seq}", s.authed(s.removeOp))
m.HandleFunc("GET "+p+"/{kind}", s.authed(s.listObjects))
m.HandleFunc("POST "+p+"/{kind}", s.authed(s.createObject))
m.HandleFunc("GET "+p+"/{kind}/{ref}", s.authed(s.getObject))
+5 -1
View File
@@ -65,6 +65,8 @@ type Firewall interface {
TableText(ctx context.Context, family, name string) (string, error)
ApplyScript(ctx context.Context, script string) error
SaveBoot(st *model.State) error
// ResetQuota starts a rule's quota again (perAddress: its sets).
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
}
// Server is the HTTP server.
@@ -86,7 +88,9 @@ type Server struct {
probation probation
logs *logRing
nowFn func() time.Time
// csMu orders changes to changesets (staging, applying, discarding).
csMu sync.Mutex
nowFn func() time.Time
// probationFor overrides the settings' probation (tests).
probationFor time.Duration
}
+184
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
@@ -35,6 +36,7 @@ type fakeFW struct {
applyErr error
deleted []string
restored []string
resets []string
}
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
@@ -88,6 +90,12 @@ func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
f.deleted = append(f.deleted, fam+" "+name)
return nil
}
func (f *fakeFW) ResetQuota(_ context.Context, id int64, perAddress bool) error {
f.mu.Lock()
defer f.mu.Unlock()
f.resets = append(f.resets, fmt.Sprintf("r%d:%v", id, perAddress))
return nil
}
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
return "table " + fam + " " + name + " {}", nil
}
@@ -538,3 +546,179 @@ func TestImportAndTakeover(t *testing.T) {
t.Errorf("taking over Housecarl's own table: %d", code)
}
}
// TestChangesets: changes staged in a changeset are checked as they are
// added, shown in reads that name the changeset, previewed with the
// ruleset's diff, and applied together as one history entry, on
// probation; another user can't use the changeset.
func TestChangesets(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 5 * time.Second
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
e.call(e.token, "POST", "/confirm", nil, nil)
applied := len(e.fw.applied)
var cs model.Changeset
if code := e.call(e.token, "POST", "/changesets", api.ChangesetInput{Description: "trusted ssh"}, &cs); code != 201 || cs.Status != "open" {
t.Fatalf("create: %d %+v", code, cs)
}
q := "?changeset=" + itoa(cs.ID)
// A rule naming a set the changeset hasn't made yet is refused.
var eb api.ErrorBody
rule := model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Action: "accept"}
if code := e.call(e.token, "POST", "/rules"+q, rule, &eb); code != 422 || eb.Error.Code != "validation" {
t.Errorf("staging an invalid rule: %d %+v", code, eb)
}
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, &mr); code != 200 || !mr.Staged || mr.Applied {
t.Fatalf("stage set: %d %+v", code, mr)
}
if code := e.call(e.token, "POST", "/rules"+q, rule, &mr); code != 200 || !mr.Staged || len(mr.Changeset.Ops) != 2 {
t.Fatalf("stage rule: %d %+v", code, mr)
}
var created model.Rule
json.Unmarshal(mr.Object, &created)
// Later operations can name what the changeset made.
if code := e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+q, map[string]any{"comment": "SSH from home"}, &mr); code != 200 || len(mr.Changeset.Ops) != 3 {
t.Fatalf("stage update: %d %+v", code, mr)
}
if code := e.call(e.token, "DELETE", "/zones/lan"+q, nil, &mr); code != 200 {
t.Fatalf("stage delete: %d %+v", code, mr)
}
// Nothing applied yet.
if len(e.fw.applied) != applied || len(e.eng.State().Sets) != 0 {
t.Fatalf("staging applied something")
}
// Reads naming the changeset show it, marked.
var zones []map[string]any
e.call(e.token, "GET", "/zones"+q, nil, &zones)
if len(zones) != 1 || zones[0]["pending"] != "deleted" {
t.Errorf("zones in the changeset: %+v", zones)
}
var rules []map[string]any
e.call(e.token, "GET", "/rules"+q+"&chain=input", nil, &rules)
if len(rules) != 1 || rules[0]["pending"] != "new" || rules[0]["comment"] != "SSH from home" {
t.Errorf("rules in the changeset: %+v", rules)
}
var plain []map[string]any
e.call(e.token, "GET", "/zones", nil, &plain)
if len(plain) != 1 || plain[0]["pending"] != nil {
t.Errorf("zones without the changeset: %+v", plain)
}
// The preview: valid, with the ruleset's diff.
var pv api.ChangesetPreview
e.call(e.token, "GET", "/changesets/"+itoa(cs.ID)+"/preview", nil, &pv)
if !pv.Valid || !strings.Contains(pv.Diff, "+\t\tip saddr @trusted_v4 tcp dport 22 counter accept") || !strings.Contains(pv.Diff, "--- ruleset.nft (now)") {
t.Errorf("preview: %+v", pv)
}
// Another user can't stage into it, apply or discard it.
ctx := context.Background()
other := &model.User{Username: "sam", Grants: auth.Roles["operator"]}
e.srv.store.CreateUser(ctx, other)
otok, pid, sh := auth.NewToken()
e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh})
if code := e.call(otok, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &eb); code != 403 || !strings.Contains(eb.Error.Message, "admin's") {
t.Errorf("another user's apply: %d %+v", code, eb)
}
// Removing an operation; then the changeset is applied as one change.
var after model.Changeset
if code := e.call(e.token, "DELETE", "/changesets/"+itoa(cs.ID)+"/ops/4", nil, &after); code != 200 || len(after.Ops) != 3 {
t.Fatalf("remove op: %d %+v", code, after)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied || mr.ConfirmBy == nil || mr.Changeset.Status != "applied" {
t.Fatalf("apply: %d %+v", code, mr)
}
if len(e.fw.applied) != applied+1 || len(e.eng.State().Rules) != 1 || len(e.eng.State().Zones) != 1 {
t.Errorf("applied: %d rulesets, state %+v", len(e.fw.applied)-applied, e.eng.State())
}
h, _ := e.srv.store.GetHistory(ctx, mr.HistoryID)
if h.Description != "trusted ssh" || len(h.Changes) != 2 {
t.Errorf("history: %+v", h)
}
// An applied changeset takes no more.
if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "x", Entries: []string{"10.0.0.1"}}, &eb); code != 409 || !strings.Contains(eb.Error.Message, "is applied") {
t.Errorf("staging into an applied changeset: %d %+v", code, eb)
}
// A changeset that no longer applies names the operation.
var cs2 model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs2)
e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+"?changeset="+itoa(cs2.ID), map[string]any{"comment": "x"}, nil)
e.call(e.token, "DELETE", "/rules/"+itoa(created.ID), nil, nil)
e.call(e.token, "GET", "/changesets/"+itoa(cs2.ID)+"/preview", nil, &pv)
if pv.Valid || pv.Error == nil || pv.Error.Op != 1 || !strings.Contains(pv.Error.Message, "operation 1") {
t.Errorf("stale preview: %+v", pv)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs2.ID)+"/discard", nil, &after); code != 200 || after.Status != "discarded" {
t.Errorf("discard: %d %+v", code, after)
}
// A dry run shows the ruleset's diff.
if code := e.call(e.token, "POST", "/sets?dry_run=1", model.AddressSet{Name: "y", Entries: []string{"10.0.0.2"}}, &mr); code != 200 || !strings.Contains(mr.Diff, "+\tset y_v4") {
t.Errorf("dry run diff: %d %q", code, mr.Diff)
}
}
func TestQuotaPeriods(t *testing.T) {
at := func(s string) time.Time { v, _ := time.ParseInLocation("2006-01-02 15:04", s, time.Local); return v }
for _, c := range []struct {
now, reset string
day int
start string
next string
}{
{"2026-10-19 10:00", "daily", 0, "2026-10-19 00:00", "2026-10-20 00:00"},
{"2026-10-19 10:00", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Monday
{"2026-10-25 23:59", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Sunday
{"2026-10-19 10:00", "monthly", 15, "2026-10-15 00:00", "2026-11-15 00:00"},
{"2026-10-10 10:00", "monthly", 15, "2026-09-15 00:00", "2026-10-15 00:00"},
{"2026-02-28 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"}, // February has no 31st
{"2026-03-15 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"},
{"2026-01-01 00:00", "monthly", 0, "2026-01-01 00:00", "2026-02-01 00:00"},
} {
now := at(c.now)
if got := periodStart(now, c.reset, c.day); !got.Equal(at(c.start)) {
t.Errorf("%s %s %d: period from %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.start)
}
if got := nextReset(now, c.reset, c.day); !got.Equal(at(c.next)) {
t.Errorf("%s %s %d: next reset %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.next)
}
}
if !periodStart(time.Now(), "", 0).IsZero() {
t.Error("a quota reset by hand has no period")
}
}
// TestQuotaResets: a quota starts its period when first seen, is reset
// when the next one starts, and by hand.
func TestQuotaResets(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
now := time.Date(2026, 10, 19, 23, 50, 0, 0, time.Local)
e.srv.nowFn = func() time.Time { return now }
var mr api.MutationResponse
rule := model.Rule{Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}}
if code := e.call(e.token, "POST", "/rules", rule, &mr); code != 201 {
t.Fatalf("rule: %d %+v", code, mr)
}
var created model.Rule
json.Unmarshal(mr.Object, &created)
e.srv.checkQuotas(ctx)
if len(e.fw.resets) != 0 {
t.Fatalf("reset on first sight: %v", e.fw.resets)
}
now = now.Add(5 * time.Minute) // the same day
e.srv.checkQuotas(ctx)
now = now.Add(10 * time.Minute) // past midnight
e.srv.checkQuotas(ctx)
e.srv.checkQuotas(ctx)
want := fmt.Sprintf("r%d:true", created.ID)
if strings.Join(e.fw.resets, ",") != want {
t.Errorf("resets %v, want one %s", e.fw.resets, want)
}
var qs []api.QuotaInfo
e.call(e.token, "GET", "/quotas", nil, &qs)
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
t.Errorf("quotas %+v", qs)
}
if code := e.call(e.token, "POST", "/rules/"+itoa(created.ID)+"/reset-quota", nil, nil); code != 200 || len(e.fw.resets) != 2 {
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
}
}