Files
housecarl/internal/server/h_config.go
T
abrown38andClaude Opus 5.5 9a165cf70b Staged undo, built-ins window, machine warnings; raw and dynamic set fixes
- Undo is staged like any edit: in review mode the web UI's Undo, and hc
  history undo with -C or HOUSECARL_CHANGESET, add a revert operation
  that checks, when replayed, that nothing it touches changed since;
  applied, History links the entries. Imports and takeovers still can't
  be staged.
- The rules page sums up a chain's built-ins in one line; a window lists
  them with what they do and their hits, and switches them.
- Warnings about the machine (dashboard, hc status): nftables.service
  enabled (it loads /etc/nftables.conf at boot and flushes every table
  when stopped), and the imported table still in force beside
  Housecarl's. The docs say to disable nftables.service but not stop it.
- A raw rule with other fields set is refused (raw_conflict) instead of
  silently ignoring them; the rule dialog's nftables section hides what
  it replaces, and the importer's raw rules carry only their text.
- A dynamic set with configured entries is refused
  (set_dynamic_entries): they were silently ignored. The set dialog
  shows entries or the entry timeout, as fits.
- The rule dialog's chain choices are just the chains' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +01:00

547 lines
14 KiB
Go

package server
import (
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
udiff "github.com/aymanbagabas/go-udiff"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
"housecarl/internal/store"
)
// 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
}
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)
out := s.mutation(res, objectOf(&op, id))
out.ConfirmToken = s.issueCheck(w, r, p, res)
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), out)
return nil
}
// 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{}
}
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
out.ConfirmBy = &pr.Deadline
}
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
if v := object(res.After); v != nil {
out.Object, _ = json.Marshal(v)
}
}
if res.DryRun && len(res.Changes) > 0 {
out.Diff = s.rulesetDiff(res.Before, res.After)
}
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, p *auth.Principal, res *engine.Result, object func(st *model.State) any, creates bool) {
out := s.mutation(res, object)
out.ConfirmToken = s.issueCheck(w, r, p, res)
writeJSON(w, s.mutationStatus(res, creates), out)
}
// 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.
var kindOf = map[string]string{
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
}
func pathKind(r *http.Request) (string, error) {
k, ok := kindOf[r.PathValue("kind")]
if !ok {
return "", &httpError{status: http.StatusNotFound, code: "not_found", msg: "no such API endpoint: " + r.URL.Path}
}
return k, nil
}
func findObject(st *model.State, kind, ref string) (model.Object, error) {
if o := st.Find(kind, ref); o != nil {
return o, nil
}
return nil, notFound("no %s %s", kind, ref)
}
// ---- objects
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
kind, err := pathKind(r)
if err != nil {
return err
}
cur, st, draft, err := s.viewState(w, r)
if err != nil {
return err
}
out := []any{}
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)
}
}
// Services: the built-in ones too, unless overridden.
if kind == model.KindService && !queryBool(r, "own") {
for _, b := range model.BuiltinServices() {
if mine := st.Find(kind, b.Name); mine == nil {
out = append(out, b)
}
}
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
}
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
kind, err := pathKind(r)
if err != nil {
return err
}
_, 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 {
writeJSON(w, http.StatusOK, b)
return nil
}
}
if err != nil {
return err
}
writeJSON(w, http.StatusOK, o)
return nil
}
// decodeObject reads a kind's object from JSON (unknown fields refused).
func decodeObject(kind string, data []byte) (model.Object, error) {
var o model.Object
switch kind {
case model.KindZone:
o = &model.Zone{}
case model.KindSet:
o = &model.AddressSet{}
case model.KindService:
o = &model.Service{}
case model.KindRule:
o = &model.Rule{}
case model.KindForward:
o = &model.PortForward{}
case model.KindSNAT:
o = &model.SourceNAT{}
case model.KindRedirect:
o = &model.Redirect{}
}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(o); err != nil {
return nil, badRequest("%s: %v", kind, err)
}
return o, nil
}
// lastPosition is after the last rule of the chain (or NAT rule of the
// kind).
func lastPosition(st *model.State, kind, chain string) float64 {
max := 0.0
for _, o := range st.Objects(kind) {
var pos float64
switch x := o.(type) {
case *model.Rule:
if x.Chain != chain {
continue
}
pos = x.Position
case *model.PortForward:
pos = x.Position
case *model.SourceNAT:
pos = x.Position
case *model.Redirect:
pos = x.Position
}
if pos > max {
max = pos
}
}
return max + 1
}
func setID(o model.Object, id int64) {
switch x := o.(type) {
case *model.Zone:
x.ID = id
case *model.AddressSet:
x.ID = id
case *model.Service:
x.ID = id
case *model.Rule:
x.ID = id
case *model.PortForward:
x.ID = id
case *model.SourceNAT:
x.ID = id
case *model.Redirect:
x.ID = id
}
}
func position(o model.Object) *float64 {
switch x := o.(type) {
case *model.Rule:
return &x.Position
case *model.PortForward:
return &x.Position
case *model.SourceNAT:
return &x.Position
case *model.Redirect:
return &x.Position
}
return nil
}
func chainOf(o model.Object) string {
if r, ok := o.(*model.Rule); ok {
return r.Chain
}
return ""
}
func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r)
if err != nil {
return err
}
var raw json.RawMessage
if err := decodeJSON(r, &raw); err != nil {
return err
}
// Refuse a bad object now, not when it is applied.
if _, err := decodeObject(kind, raw); err != nil {
return err
}
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 {
kind, err := pathKind(r)
if err != nil {
return err
}
var patch map[string]json.RawMessage
if err := decodeJSON(r, &patch); err != nil {
return err
}
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 {
kind, err := pathKind(r)
if err != nil {
return err
}
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
// referred to by name), or "".
func usedBy(st *model.State, kind string, o model.Object) string {
name := o.Label()
has := func(list []string) bool {
for _, x := range list {
if strings.EqualFold(x, name) {
return true
}
}
return false
}
uses := func(m model.Match) bool {
switch kind {
case model.KindZone:
return has(m.InZones) || has(m.OutZones)
case model.KindSet:
return has(m.Sources) || has(m.Destinations)
case model.KindService:
return has(m.Services)
}
return false
}
for _, x := range st.Rules {
if uses(x.Match) {
return x.Label()
}
}
for _, x := range st.SNATs {
if uses(x.Match) {
return x.Label()
}
}
for _, x := range st.Redirects {
if uses(x.Match) {
return x.Label()
}
}
if kind == model.KindSet {
for _, f := range st.Forwards {
if has(f.Sources) || has(f.PublicAddresses) || has(f.HairpinSources) {
return f.Label()
}
}
}
return ""
}
// 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 {
return err
}
var in api.MoveInput
if err := decodeJSON(r, &in); err != nil {
return err
}
data, _ := json.Marshal(in)
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
}
// ---- settings
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
_, st, _, err := s.viewState(w, r)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, st.Settings)
return nil
}
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var patch map[string]json.RawMessage
if err := decodeJSON(r, &patch); err != nil {
return err
}
data, _ := json.Marshal(patch)
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
}
// ---- the ruleset
func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
_, 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
}
// ---- history
func (s *Server) listHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
hs, err := s.store.ListHistory(r.Context(), queryInt(r, "limit", 100), int64(queryInt(r, "before", 0)))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, hs)
return nil
}
func (s *Server) getHistory(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
}
h, err := s.store.GetHistory(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("history entry %d not found", id)
}
if err != nil {
return err
}
writeJSON(w, http.StatusOK, h)
return nil
}
func (s *Server) revertHistory(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
}
// Staged, an undo is an operation of its own, reviewed like any.
if csID, err := changesetParam(r); err != nil {
return err
} else if csID != 0 {
h, err := s.store.GetHistory(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return notFound("history entry %d not found", id)
}
if err != nil {
return err
}
if h.RevertedBy != 0 {
return conflict("history entry %d was already undone by entry %d", id, h.RevertedBy)
}
data, _ := json.Marshal(h)
return s.stage(w, r, p, csID, model.Op{Op: model.OpRevert, ID: id, Data: data})
}
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
if err != nil {
s.writeErr(w, r, err, res)
return nil
}
s.afterChange(r.Context(), p, res)
s.respondChange(w, r, p, res, nil, false)
return nil
}
// ---- probation
func (s *Server) getProbation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
writeJSON(w, http.StatusOK, s.probationStatus())
return nil
}
func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
var in api.ConfirmInput
if r.ContentLength != 0 {
if err := decodeJSON(r, &in); err != nil {
return err
}
}
confirm := func() (int, error) { return s.confirm(r.Context(), p) }
if in.Token != "" {
confirm = func() (int, error) { return s.confirmToken(r, p, in.Token) }
}
n, err := confirm()
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]int{"confirmed": n})
return nil
}
func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
ps := s.probationStatus()
n := len(ps.Pending) + ps.Takeovers
if n > 0 {
s.rollback("rolled back by " + p.Name())
}
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
return nil
}