Changesets, as in Jarl: configuration changes are operations that can be staged in a changeset, checked as they are added (validation and nft -c), reviewed with the diff of the ruleset, and applied together as one history entry, then on probation like any change made over the network. The web UI collects edits for review by default (pending rows, a bar with Review and Apply now, a Changes page, a switch for live edits); hc stages with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has hc changeset create/list/show/diff/apply/discard/remove. Undo, import and takeover refuse a changeset. --dry-run shows the ruleset's diff. Bandwidth and quotas: rate limits in bytes, per source or destination; quotas per rule or per address that reset daily, weekly, monthly on a day, or by hand, with their usage carried across reloads, saved for reboots, shown by hc quotas and on the rules page, and reset with hc rule reset-quota. Rules that count traffic are checked before established connections are accepted (so they see every packet) and can't accept. Per-address limits group IPv6 by /64 by default (--group-v4/-v6). Per-source rate meters use update. The importer turns meters, byte rates and quotas into these fields. The examples in the web UI, hc's help and the docs use documentation addresses and generic names instead of the maintainer's network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
571 lines
15 KiB
Go
571 lines
15 KiB
Go
// Package engine changes Housecarl's configuration. Every change, from the
|
|
// API, the CLI or an import, goes through Apply: the change is made on a
|
|
// copy of the state, the result is validated as a whole, the ruleset built
|
|
// from it is checked by nftables and put in force, and only then are the
|
|
// changed objects and a history entry saved together (if saving fails, the
|
|
// previous ruleset is put back). Revert undoes a history entry the same
|
|
// way.
|
|
package engine
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/store"
|
|
"housecarl/internal/validate"
|
|
)
|
|
|
|
// Kinds of errors, for the API's status codes.
|
|
const (
|
|
KindBadRequest = "bad_request"
|
|
KindNotFound = "not_found"
|
|
KindConflict = "conflict"
|
|
KindValidation = "validation"
|
|
// KindFirewall: nftables refused or failed to apply the ruleset.
|
|
KindFirewall = "firewall"
|
|
)
|
|
|
|
// Firewall puts configurations in force (internal/nft, through
|
|
// internal/render).
|
|
type Firewall interface {
|
|
// Check builds the ruleset and has nftables check it, changing
|
|
// nothing.
|
|
Check(ctx context.Context, st *model.State) error
|
|
// Apply puts the configuration's ruleset in force, in one
|
|
// transaction.
|
|
Apply(ctx context.Context, st *model.State) error
|
|
}
|
|
|
|
// Error is a refused change.
|
|
type Error struct {
|
|
Kind string `json:"code"`
|
|
Message string `json:"message"`
|
|
Problems []validate.Problem `json:"problems,omitempty"`
|
|
}
|
|
|
|
func (e *Error) Error() string { return e.Message }
|
|
|
|
// Errorf makes an Error.
|
|
func Errorf(kind, format string, args ...any) *Error {
|
|
return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// AsError unwraps an Error.
|
|
func AsError(err error) (*Error, bool) {
|
|
var e *Error
|
|
ok := errors.As(err, &e)
|
|
return e, ok
|
|
}
|
|
|
|
// Result describes an applied (or previewed) change.
|
|
type Result struct {
|
|
Applied bool `json:"applied"`
|
|
DryRun bool `json:"dry_run,omitempty"`
|
|
HistoryID int64 `json:"history_id,omitempty"`
|
|
Changes []model.Change `json:"changes"`
|
|
Warnings []validate.Problem `json:"warnings,omitempty"`
|
|
// Before and After are the configurations the change goes from and
|
|
// to (After also for a dry run), for diffs and answers.
|
|
Before, After *model.State `json:"-"`
|
|
}
|
|
|
|
// Engine holds the current configuration.
|
|
type Engine struct {
|
|
store *store.Store
|
|
log *slog.Logger
|
|
// Env describes the host's interfaces for validation (nil: skip).
|
|
Env func() *validate.Env
|
|
// Firewall puts each new state in force (nil: none, in tests that
|
|
// only look at the configuration).
|
|
Firewall Firewall
|
|
// OnChange is called with each new state, after it was saved.
|
|
OnChange func(*model.State)
|
|
|
|
mu sync.Mutex
|
|
state *model.State
|
|
}
|
|
|
|
// New loads the configuration.
|
|
func New(ctx context.Context, st *store.Store, log *slog.Logger) (*Engine, error) {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
s, err := st.LoadState(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
Normalize(s)
|
|
return &Engine{store: st, log: log, state: s}, nil
|
|
}
|
|
|
|
// Store is the engine's database.
|
|
func (e *Engine) Store() *store.Store { return e.store }
|
|
|
|
// State returns a copy of the current configuration.
|
|
func (e *Engine) State() *model.State {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.state.Clone()
|
|
}
|
|
|
|
func (e *Engine) env() *validate.Env {
|
|
if e.Env == nil {
|
|
return nil
|
|
}
|
|
return e.Env()
|
|
}
|
|
|
|
// Problems validates the current configuration (for the status page).
|
|
func (e *Engine) Problems() []validate.Problem {
|
|
return validate.Check(e.State(), e.env())
|
|
}
|
|
|
|
// Edit changes a configuration in place. NewID gives IDs for new objects.
|
|
type Edit func(st *model.State, newID func() int64) error
|
|
|
|
// Options say how to apply a change.
|
|
type Options struct {
|
|
DryRun bool
|
|
Via string // web, cli, api, import
|
|
}
|
|
|
|
// Apply makes a change: edit runs on a copy of the configuration, the
|
|
// result is validated, and the changed objects are saved with a history
|
|
// entry. A change that touches nothing is not recorded.
|
|
func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description string, opts Options, edit Edit) (*Result, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
next := e.state.Clone()
|
|
var idErr error
|
|
newID := func() int64 {
|
|
ids, err := e.store.AllocateIDs(ctx, 1)
|
|
if err != nil {
|
|
idErr = err
|
|
return 0
|
|
}
|
|
return ids[0]
|
|
}
|
|
if err := edit(next, newID); err != nil {
|
|
return nil, err
|
|
}
|
|
if idErr != nil {
|
|
return nil, idErr
|
|
}
|
|
return e.commit(ctx, p, description, opts, next, 0)
|
|
}
|
|
|
|
// commit validates next and saves it as the new state.
|
|
func (e *Engine) commit(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State, revertOf int64) (*Result, error) {
|
|
Normalize(next)
|
|
next.Sort()
|
|
changes := Diff(e.state, next)
|
|
res := &Result{DryRun: opts.DryRun, Changes: changes, Before: e.state.Clone(), After: next.Clone()}
|
|
if len(changes) == 0 {
|
|
return res, nil
|
|
}
|
|
problems := validate.Check(next, e.env())
|
|
if validate.HasErrors(problems) {
|
|
errs := validate.Errors(problems)
|
|
msg := errs[0].Message
|
|
if errs[0].Object != "" {
|
|
msg = errs[0].Object + ": " + msg
|
|
}
|
|
if len(errs) > 1 {
|
|
msg += fmt.Sprintf(" (and %d more problems)", len(errs)-1)
|
|
}
|
|
return res, &Error{Kind: KindValidation, Message: msg, Problems: errs}
|
|
}
|
|
// Only report warnings the change brings; old ones are on the status
|
|
// page.
|
|
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
|
if e.Firewall != nil {
|
|
if err := e.Firewall.Check(ctx, next); err != nil {
|
|
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
|
|
}
|
|
}
|
|
if opts.DryRun {
|
|
return res, nil
|
|
}
|
|
if e.Firewall != nil {
|
|
if err := e.Firewall.Apply(ctx, next); err != nil {
|
|
return res, &Error{Kind: KindFirewall, Message: "applying the ruleset failed, nothing was changed: " + err.Error()}
|
|
}
|
|
}
|
|
entry := &model.HistoryEntry{Time: time.Now(), Username: p.Name(), Via: opts.Via, Description: description, Changes: changes, RevertOf: revertOf}
|
|
if p != nil && p.User != nil {
|
|
entry.UserID = p.User.ID
|
|
}
|
|
if entry.Description == "" {
|
|
entry.Description = Describe(changes)
|
|
}
|
|
if err := e.store.SaveChanges(ctx, changes, entry); err != nil {
|
|
// The new ruleset is in force but not saved: put the old one back.
|
|
if e.Firewall != nil {
|
|
if rerr := e.Firewall.Apply(ctx, e.state); rerr != nil {
|
|
e.log.Error("saving a change failed, and putting the previous ruleset back failed too", "err", rerr)
|
|
}
|
|
}
|
|
return res, err
|
|
}
|
|
e.state = next
|
|
res.Applied, res.HistoryID = true, entry.ID
|
|
e.Audit(ctx, p, "config.change", fmt.Sprintf("history %d", entry.ID), "success", entry.Description)
|
|
e.log.Info("configuration changed", "history", entry.ID, "user", p.Name(), "via", opts.Via, "description", entry.Description)
|
|
if e.OnChange != nil {
|
|
e.OnChange(next.Clone())
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func newProblems(before, after []validate.Problem) []validate.Problem {
|
|
seen := map[string]bool{}
|
|
for _, p := range before {
|
|
seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] = true
|
|
}
|
|
var out []validate.Problem
|
|
for _, p := range after {
|
|
if !seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ApplyCurrent puts the current configuration in force (at start).
|
|
func (e *Engine) ApplyCurrent(ctx context.Context) error {
|
|
if e.Firewall == nil {
|
|
return nil
|
|
}
|
|
return e.Firewall.Apply(ctx, e.State())
|
|
}
|
|
|
|
// Revert undoes a history entry, if the objects it changed are still as
|
|
// it left them.
|
|
func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts Options) (*Result, error) {
|
|
h, err := e.store.GetHistory(ctx, id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, Errorf(KindNotFound, "history entry %d not found", id)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if h.RevertedBy != 0 {
|
|
return nil, Errorf(KindConflict, "history entry %d was already undone by entry %d", id, h.RevertedBy)
|
|
}
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
cur := objects(e.state)
|
|
next := e.state.Clone()
|
|
for i := len(h.Changes) - 1; i >= 0; i-- {
|
|
c := h.Changes[i]
|
|
now := cur[key{c.Kind, c.ID}]
|
|
if !sameJSON(now, c.After) {
|
|
what := c.Label
|
|
if what == "" {
|
|
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
|
|
}
|
|
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id)
|
|
}
|
|
if err := put(next, c.Kind, c.ID, c.Before); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID)
|
|
}
|
|
|
|
// Replace makes the configuration next as a whole (imports).
|
|
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.commit(ctx, p, description, opts, next.Clone(), 0)
|
|
}
|
|
|
|
// Audit records an action in the audit log.
|
|
func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, object, result, detail string) {
|
|
ev := &model.AuditEvent{Action: action, Object: object, Result: result, Detail: detail}
|
|
if p != nil {
|
|
ev.Username, ev.Auth, ev.IP = p.Name(), p.Method, p.IP
|
|
if p.User != nil {
|
|
ev.UserID = p.User.ID
|
|
}
|
|
if p.Token != nil {
|
|
ev.TokenID = p.Token.ID
|
|
}
|
|
}
|
|
if err := e.store.AddAudit(ctx, ev); err != nil {
|
|
e.log.Warn("writing the audit log failed", "err", err)
|
|
}
|
|
}
|
|
|
|
// ---- diffs
|
|
|
|
type key struct {
|
|
kind string
|
|
id int64
|
|
}
|
|
|
|
func objects(st *model.State) map[key]json.RawMessage {
|
|
m := map[key]json.RawMessage{{model.KindSettings, 0}: mustJSON(st.Settings)}
|
|
for _, k := range model.Kinds {
|
|
for _, o := range st.Objects(k) {
|
|
m[key{k, o.GetID()}] = mustJSON(o)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func labels(st *model.State) map[key]string {
|
|
m := map[key]string{{model.KindSettings, 0}: "settings"}
|
|
for _, k := range model.Kinds {
|
|
for _, o := range st.Objects(k) {
|
|
m[key{k, o.GetID()}] = o.Label()
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Diff lists what changes from a to b: settings, then each kind of
|
|
// object (zones, sets, services, rules, NAT), each by ID.
|
|
func Diff(a, b *model.State) []model.Change {
|
|
oa, ob := objects(a), objects(b)
|
|
la, lb := labels(a), labels(b)
|
|
var keys []key
|
|
for k := range oa {
|
|
keys = append(keys, k)
|
|
}
|
|
for k := range ob {
|
|
if _, ok := oa[k]; !ok {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
order := map[string]int{model.KindSettings: 0}
|
|
for i, k := range model.Kinds {
|
|
order[k] = i + 1
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
if keys[i].kind != keys[j].kind {
|
|
return order[keys[i].kind] < order[keys[j].kind]
|
|
}
|
|
return keys[i].id < keys[j].id
|
|
})
|
|
var out []model.Change
|
|
for _, k := range keys {
|
|
before, after := oa[k], ob[k]
|
|
if sameJSON(before, after) {
|
|
continue
|
|
}
|
|
label := lb[k]
|
|
if label == "" {
|
|
label = la[k]
|
|
}
|
|
out = append(out, model.Change{Kind: k.kind, ID: k.id, Label: label, Before: before, After: after})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Describe sums up changes: "create zone lan (eth1)", or
|
|
// "3 changes" for many.
|
|
func Describe(cs []model.Change) string {
|
|
if len(cs) == 0 {
|
|
return "no changes"
|
|
}
|
|
parts := []string{}
|
|
for _, c := range cs {
|
|
if c.Kind == model.KindSettings {
|
|
parts = append(parts, "update settings")
|
|
continue
|
|
}
|
|
parts = append(parts, c.Action()+" "+c.Kind+" "+c.Label)
|
|
}
|
|
if len(parts) > 3 {
|
|
return fmt.Sprintf("%s and %d more changes", strings.Join(parts[:2], ", "), len(parts)-2)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func mustJSON(v any) json.RawMessage {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func sameJSON(a, b json.RawMessage) bool {
|
|
if a == nil || b == nil {
|
|
return a == nil && b == nil
|
|
}
|
|
return bytes.Equal(a, b)
|
|
}
|
|
|
|
// put sets (or with nil data deletes) one object in st.
|
|
func put(st *model.State, kind string, id int64, data json.RawMessage) error {
|
|
if kind == model.KindSettings {
|
|
if data == nil {
|
|
return fmt.Errorf("settings can't be deleted")
|
|
}
|
|
var set model.Settings
|
|
if err := json.Unmarshal(data, &set); err != nil {
|
|
return err
|
|
}
|
|
st.Settings = set
|
|
return nil
|
|
}
|
|
return st.Put(kind, id, data)
|
|
}
|
|
|
|
// Normalize writes names, addresses, ports and lists in their canonical
|
|
// form, so equal configurations compare equal, and spaces rule positions
|
|
// out again when insertions have crowded them.
|
|
func Normalize(st *model.State) {
|
|
st.Fill()
|
|
set := &st.Settings
|
|
set.AntispoofV4 = normAddrs(set.AntispoofV4)
|
|
set.AntispoofV6 = normAddrs(set.AntispoofV6)
|
|
set.OwnPrefixes = normAddrs(set.OwnPrefixes)
|
|
set.FlowtableDevices = normList(set.FlowtableDevices)
|
|
for _, z := range st.Zones {
|
|
z.Name = strings.TrimSpace(z.Name)
|
|
z.Interfaces = normList(z.Interfaces)
|
|
}
|
|
for _, x := range st.Sets {
|
|
x.Name = strings.TrimSpace(x.Name)
|
|
x.Entries = normAddrs(x.Entries)
|
|
}
|
|
for _, x := range st.Services {
|
|
x.Name = strings.TrimSpace(x.Name)
|
|
x.Entries = normServices(x.Entries)
|
|
}
|
|
for _, r := range st.Rules {
|
|
normMatch(&r.Match)
|
|
r.CtState = normList(lower(r.CtState))
|
|
r.CtStatus = normList(lower(r.CtStatus))
|
|
r.Raw = strings.TrimSpace(r.Raw)
|
|
}
|
|
for _, f := range st.Forwards {
|
|
f.Protocols = normList(lower(f.Protocols))
|
|
f.PublicAddresses = normAddrs(f.PublicAddresses)
|
|
f.Sources = normAddrs(f.Sources)
|
|
f.HairpinSources = normAddrs(f.HairpinSources)
|
|
f.Ports = normPorts(f.Ports)
|
|
f.ToPort = normPorts(f.ToPort)
|
|
f.To = normAddr(f.To)
|
|
f.To6 = normAddr(f.To6)
|
|
}
|
|
for _, x := range st.SNATs {
|
|
normMatch(&x.Match)
|
|
x.To = normAddr(x.To)
|
|
}
|
|
for _, x := range st.Redirects {
|
|
normMatch(&x.Match)
|
|
x.ToPort = normPorts(x.ToPort)
|
|
}
|
|
// Positions are fractions after insertions between rules; renumber
|
|
// only when two have come too close to put another between.
|
|
st.Sort()
|
|
crowded := func(pos []float64) bool {
|
|
for i := 1; i < len(pos); i++ {
|
|
if pos[i]-pos[i-1] < 1e-6 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
byChain := map[string][]float64{}
|
|
for _, r := range st.Rules {
|
|
byChain[r.Chain] = append(byChain[r.Chain], r.Position)
|
|
}
|
|
re := false
|
|
for _, pos := range byChain {
|
|
re = re || crowded(pos)
|
|
}
|
|
var fp, sp, rp []float64
|
|
for _, x := range st.Forwards {
|
|
fp = append(fp, x.Position)
|
|
}
|
|
for _, x := range st.SNATs {
|
|
sp = append(sp, x.Position)
|
|
}
|
|
for _, x := range st.Redirects {
|
|
rp = append(rp, x.Position)
|
|
}
|
|
if re || crowded(fp) || crowded(sp) || crowded(rp) {
|
|
st.Renumber()
|
|
}
|
|
}
|
|
|
|
func normMatch(m *model.Match) {
|
|
m.InZones = normList(m.InZones)
|
|
m.OutZones = normList(m.OutZones)
|
|
m.Sources = normAddrs(m.Sources)
|
|
m.Destinations = normAddrs(m.Destinations)
|
|
m.Services = normServices(m.Services)
|
|
m.Family = strings.ToLower(strings.TrimSpace(m.Family))
|
|
}
|
|
|
|
// normList trims, drops empty entries and duplicates, keeping the order.
|
|
func normList(in []string) []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s != "" && !seen[s] {
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func lower(in []string) []string {
|
|
out := make([]string, len(in))
|
|
for i, s := range in {
|
|
out[i] = strings.ToLower(s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normAddrs writes addresses canonically; set names stay as they are.
|
|
func normAddrs(in []string) []string {
|
|
out := normList(in)
|
|
for i, s := range out {
|
|
out[i] = normAddr(s)
|
|
}
|
|
return normList(out)
|
|
}
|
|
|
|
func normAddr(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if n, err := model.NormalizeAddress(s); err == nil {
|
|
return n
|
|
}
|
|
return s
|
|
}
|
|
|
|
func normServices(in []string) []string {
|
|
out := normList(in)
|
|
for i, s := range out {
|
|
if e, err := model.ParseServiceEntry(s); err == nil {
|
|
out[i] = e.String()
|
|
}
|
|
}
|
|
return normList(out)
|
|
}
|
|
|
|
func normPorts(s string) string {
|
|
if n, err := model.NormalizePorts(s); err == nil {
|
|
return n
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|