The Rules page has a Move… button that puts a rule before any other of its chain, or at the end, in one move. Staged in a changeset, a move right after a move of the same object takes its place, so pressing ↑ three times is one operation; moving it back where it was takes the operation out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
528 lines
15 KiB
Go
528 lines
15 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 (an
|
|
// import, a 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)
|
|
}
|
|
// A move right after a move of the same object takes its place:
|
|
// moving a rule up three steps is one operation.
|
|
kept := cs.Ops
|
|
if n := len(kept); n > 0 && foldMove(before, &kept[n-1], &op) {
|
|
kept = kept[:n-1]
|
|
before = s.eng.State()
|
|
if err := replay(before, kept); err != nil {
|
|
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
|
|
}
|
|
}
|
|
folded := len(kept) < len(cs.Ops)
|
|
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 {
|
|
// Moved back where it was: the earlier move goes too.
|
|
if folded && !queryBool(r, "dry_run") {
|
|
cs.Ops = kept
|
|
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, api.MutationResponse{Changes: []model.Change{}, Changeset: cs})
|
|
return nil
|
|
}
|
|
op.Summary = engine.Describe(changes)
|
|
if op.Op == model.OpRevert {
|
|
var h model.HistoryEntry
|
|
json.Unmarshal(op.Data, &h)
|
|
op.Summary = engine.UndoDescription(&h)
|
|
}
|
|
op.AddedAt = s.nowFn()
|
|
for _, o := range kept {
|
|
if o.Seq >= op.Seq {
|
|
op.Seq = o.Seq + 1
|
|
}
|
|
}
|
|
if op.Seq == 0 {
|
|
op.Seq = 1
|
|
}
|
|
ops := append(append([]model.Op{}, kept...), 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
|
|
}
|
|
|
|
// foldMove reports whether op, a move, can take the place of prev, the
|
|
// changeset's last operation: a move of the same object. A move puts
|
|
// the object relative to the others, which an earlier move of it
|
|
// doesn't change, so only the chain it moved to has to be carried over.
|
|
// st is the changeset so far.
|
|
func foldMove(st *model.State, prev, op *model.Op) bool {
|
|
if prev.Op != model.OpMove || op.Op != model.OpMove || prev.Kind != op.Kind {
|
|
return false
|
|
}
|
|
a, err1 := findObject(st, prev.Kind, prev.Ref)
|
|
b, err2 := findObject(st, op.Kind, op.Ref)
|
|
if err1 != nil || err2 != nil || a.GetID() != b.GetID() {
|
|
return false
|
|
}
|
|
var was, now api.MoveInput
|
|
if json.Unmarshal(prev.Data, &was) != nil || json.Unmarshal(op.Data, &now) != nil {
|
|
return false
|
|
}
|
|
if now.Chain == "" && was.Chain != "" {
|
|
now.Chain = was.Chain
|
|
op.Data, _ = json.Marshal(now)
|
|
}
|
|
return true
|
|
}
|
|
|
|
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)
|
|
}
|
|
opts := engine.Options{Via: via(r, p)}
|
|
desc := cs.Description
|
|
for _, o := range cs.Ops {
|
|
if o.Op == model.OpRevert {
|
|
opts.RevertOf = o.ID // History links the undo
|
|
if desc == "" && len(cs.Ops) == 1 {
|
|
desc = o.Summary
|
|
}
|
|
}
|
|
}
|
|
res, err := s.eng.Apply(r.Context(), p, desc, opts, 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.ConfirmToken = s.issueCheck(w, r, p, res)
|
|
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
|
|
}
|