Files
abrown38andClaude Opus 5.5 05c811bddc Rules: placed in the same change they are created in
hc rule add --before/--after/--first created the rule at the end of its
chain and then moved it: two history entries, two probations, the rule
briefly in force in the wrong place, and in a changeset the place was
lost. POST /rules (and the other ordered kinds) now takes ?before=ID,
?after=ID or ?first=1, kept with the operation when staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:18:37 +01:00

672 lines
17 KiB
Go

package server
import (
"encoding/json"
"errors"
"net/http"
"net/netip"
"sort"
"strconv"
"strings"
udiff "github.com/aymanbagabas/go-udiff"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/matrix"
"housecarl/internal/model"
"housecarl/internal/nft"
"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
}
op := model.Op{Op: model.OpCreate, Kind: kind, Data: raw}
// ?before=ID, ?after=ID or ?first=1: where it goes in its order.
q := r.URL.Query()
var place api.MoveInput
place.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
place.After, _ = strconv.ParseInt(q.Get("after"), 10, 64)
if queryBool(r, "first") {
place.To = "start"
}
if place.Before != 0 || place.After != 0 || place.To != "" {
op.Place, _ = json.Marshal(place)
}
return s.change(w, r, p, op)
}
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 ""
}
// renameRefs makes every object that refers to a zone, set or service by
// its old name use the new one: renaming it changes them in the same
// change, so the history shows (and undoes) them together.
func renameRefs(st *model.State, kind, old, now string) {
fix := func(list []string) {
for i, x := range list {
if strings.EqualFold(x, old) {
list[i] = now
}
}
}
match := func(m *model.Match) {
switch kind {
case model.KindZone:
fix(m.InZones)
fix(m.OutZones)
case model.KindSet:
fix(m.Sources)
fix(m.Destinations)
case model.KindService:
fix(m.Services)
}
}
for _, x := range st.Rules {
match(&x.Match)
}
for _, x := range st.SNATs {
match(&x.Match)
}
for _, x := range st.Redirects {
match(&x.Match)
}
if kind == model.KindSet {
for _, f := range st.Forwards {
fix(f.Sources)
fix(f.PublicAddresses)
fix(f.HairpinSources)
}
}
}
// 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 zone matrix
// zoneMatrix answers what the rules do to new connections between zones
// (internal/matrix); with a changeset named, as it will make them.
func (s *Server) zoneMatrix(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
}
m := matrix.Build(st, matrix.Options{Connected: s.connected(st)})
if t, err := s.fw.Table(r.Context()); err == nil {
matrixHits(&m, t.Counters)
}
writeJSON(w, http.StatusOK, m)
return nil
}
// matrixHits fills in the hits of the matrix's rules, built-ins and
// policies from the counters.
func matrixHits(m *api.Matrix, counters map[string]nft.Counter) {
hits := func(marks []string) *uint64 {
var n uint64
found := false
for _, mk := range marks {
if c, ok := counters[mk]; ok {
n, found = n+c.Packets, true
}
}
if !found {
return nil
}
return &n
}
for i := range m.Cells {
c := &m.Cells[i]
c.Hits = hits(c.Marks)
for j := range c.Exceptions {
c.Exceptions[j].Hits = hits(c.Exceptions[j].Marks)
}
}
}
// connected maps the networks on the firewall's interfaces (link-local
// and loopback aside) to their zones.
func (s *Server) connected(st *model.State) map[netip.Prefix]string {
out := map[netip.Prefix]string{}
for _, i := range s.hostInterfaces(st) {
if i.Zone == "" {
continue
}
for _, a := range i.Addresses {
p, err := netip.ParsePrefix(a)
if err != nil || p.Addr().IsLinkLocalUnicast() || p.Addr().IsLoopback() {
continue
}
out[p.Masked()] = i.Zone
}
}
return out
}
// ---- 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)
// In shadow mode: what would be loaded once it is off.
if queryBool(r, "enforcing") {
rs = s.fw.RenderEnforcing(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
}