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>
469 lines
13 KiB
Go
469 lines
13 KiB
Go
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
|
|
}
|