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