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>
629 lines
16 KiB
Go
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
|
|
}
|