Files
housecarl/internal/server/h_config.go
T
abrown38andClaude Opus 5.5 de075edd5d Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it
blocks, rejects and translates nothing: drops and rejects count and
accept (an accept only ends Housecarl's chain), policies accept and log
what they would drop, NAT only counts, MSS clamping and the flowtable
are left out, and raw rules that do more than accept, drop or reject are
left out with a warning. Comments mark what would happen ([would drop]).
Filter chains run after the other firewall's (+10) or before (-10); NAT
chains always first. Every change is checked by nftables in both forms.
No probation; takeovers and closing connections are refused. The boot
fallback filters nothing. internal/tablewatch puts the table back after
a flush ruleset. Mode in status, healthz, whoami, the web UI, the log
and the tracer; hc ruleset --enforcing. docs/shadow.md.

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

629 lines
16 KiB
Go

package server
import (
"encoding/json"
"errors"
"net/http"
"net/netip"
"sort"
"strings"
udiff "github.com/aymanbagabas/go-udiff"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/matrix"
"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 ""
}
// 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
}
writeJSON(w, http.StatusOK, matrix.Build(st, matrix.Options{Connected: s.connected(st)}))
return nil
}
// 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
}