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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 14:52:55 +01:00
1 parent d25e12995e
commit 9a165cf70b
26 files changed
+408 -58

No files matched your search

+18 -3
View File
@@ -36,8 +36,8 @@ func changesetParam(r *http.Request) (int64, error) {
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.
// 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
@@ -102,6 +102,11 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
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 cs.Ops {
if o.Seq >= op.Seq {
@@ -353,7 +358,17 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
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 {
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 {
+17 -3
View File
@@ -469,13 +469,27 @@ 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
}
// 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)
+5
View File
@@ -51,6 +51,11 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
return nil
}
s.afterChange(r.Context(), p, er)
if er.Applied {
// Until it is taken over, the old table still applies too: the
// status warns (takeover_pending).
s.store.SetMeta(r.Context(), importedTableKey, res.Table)
}
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
if out.Changes == nil {
out.Changes = []model.Change{}
+36
View File
@@ -1,7 +1,10 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"sort"
"strings"
"sync"
@@ -44,10 +47,43 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
out.OtherTables = otherTables(data)
}
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
writeJSON(w, http.StatusOK, out)
return nil
}
const importedTableKey = "imported_table"
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
// enables it for sysinit.target).
var nftablesUnitLinks = []string{
"/etc/systemd/system/sysinit.target.wants/nftables.service",
"/etc/systemd/system/multi-user.target.wants/nftables.service",
}
// hostProblems are warnings about the machine rather than the
// configuration: another ruleset that still loads at boot, or the table
// an import came from, still in force beside Housecarl's.
func (s *Server) hostProblems(ctx context.Context, others []string) []validate.Problem {
var out []validate.Problem
for _, link := range s.unitLinks {
if _, err := os.Lstat(link); err == nil {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "nftables_service", Object: "nftables.service",
Message: "nftables.service is enabled: at boot it loads /etc/nftables.conf, and stopping or restarting it flushes every table, Housecarl's included; disable it (systemctl disable nftables, without --now)"})
break
}
}
if t, err := s.store.GetMeta(ctx, importedTableKey); err == nil && t != "" {
for _, o := range others {
if o == t {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "takeover_pending", Object: "table " + t,
Message: fmt.Sprintf("%s, the table you imported, still applies beside Housecarl's: a packet must get past both; once Housecarl's rules are right, delete it with 'hc takeover %q'", t, t)})
}
}
}
return out
}
// hostInterfaces lists the machine's interfaces with their zones.
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
out := []api.HostInterface{}
+6
View File
@@ -31,6 +31,12 @@ func applyOp(st *model.State, op *model.Op) (int64, error) {
return moveOp(st, op)
case model.OpSettings:
return 0, settingsOp(st, op)
case model.OpRevert:
var h model.HistoryEntry
if err := json.Unmarshal(op.Data, &h); err != nil {
return 0, badRequest("revert: %v", err)
}
return 0, engine.Undo(st, &h)
}
return 0, badRequest("unknown operation %q", op.Op)
}
+4 -1
View File
@@ -88,6 +88,9 @@ type Server struct {
probation probation
logs *logRing
// unitLinks are where an enabled nftables.service shows (tests change
// them).
unitLinks []string
// csMu orders changes to changesets (staging, applying, discarding).
csMu sync.Mutex
nowFn func() time.Time
@@ -113,7 +116,7 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
log = slog.Default()
}
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now}
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now, unitLinks: nftablesUnitLinks}
s.routes()
return s
}
+70
View File
@@ -795,3 +795,73 @@ func TestAutoConfirm(t *testing.T) {
t.Errorf("manual confirmation: %+v", mr)
}
}
// TestStagedUndo: an undo staged in a changeset changes nothing until the
// changeset is applied; then History links the two entries.
func TestStagedUndo(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 5 * time.Second
var mr api.MutationResponse
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &mr)
e.call(e.token, "POST", "/confirm", nil, nil)
made := mr.HistoryID
var cs model.Changeset
e.call(e.token, "POST", "/changesets", nil, &cs)
q := "?changeset=" + itoa(cs.ID)
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &mr); code != 200 || !mr.Staged {
t.Fatalf("staging the undo: %d %+v", code, mr)
}
if op := mr.Changeset.Ops[0]; op.Op != model.OpRevert || !strings.HasPrefix(op.Summary, "undo "+itoa(made)+":") {
t.Errorf("the operation: %+v", op)
}
if len(e.eng.State().Zones) != 1 {
t.Fatal("a staged undo was applied")
}
// Staging it again can't work: the changeset already undoes it.
var eb api.ErrorBody
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &eb); code != 409 {
t.Errorf("a second undo: %d %+v", code, eb)
}
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied {
t.Fatalf("apply: %d %+v", code, mr)
}
if len(e.eng.State().Zones) != 0 {
t.Error("the undo wasn't applied")
}
h, _ := e.srv.store.GetHistory(context.Background(), mr.HistoryID)
orig, _ := e.srv.store.GetHistory(context.Background(), made)
if h.RevertOf != made || orig.RevertedBy != h.ID || !strings.HasPrefix(h.Description, "undo ") {
t.Errorf("history: %+v, original %+v", h, orig)
}
}
// TestHostProblems: an enabled nftables.service, and an imported table
// still in force, are warned about.
func TestHostProblems(t *testing.T) {
e := newEnv(t)
dir := t.TempDir()
link := dir + "/nftables.service"
e.srv.unitLinks = []string{link}
codes := func() string {
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
var out []string
for _, p := range st.Problems {
out = append(out, p.Code)
}
return strings.Join(out, " ")
}
if c := codes(); strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") {
t.Errorf("warned without cause: %s", c)
}
os.Symlink("/usr/lib/systemd/system/nftables.service", link) // dangling is fine: Lstat
// The fake ruleset lists inet main: imported from it, it still applies.
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
if c := codes(); !strings.Contains(c, "nftables_service") || !strings.Contains(c, "takeover_pending") {
t.Errorf("not warned: %s", c)
}
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet gone")
if c := codes(); strings.Contains(c, "takeover_pending") {
t.Errorf("warned about a table that is gone: %s", c)
}
}