Build Housecarl 0.1.0: the nftables firewall manager

Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
package server
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
)
// A change made over the network is applied on probation: unless it is
// confirmed in time (POST /confirm, hc confirm, the web UI's banner), it is
// undone, so a change that cuts the user off from the firewall undoes
// itself. Changes through the local socket (root on the firewall) need no
// confirmation, but join a probation already running, so a rollback undoes
// everything since the last confirmed state. The pending entries are kept
// in the database: housecarld starting with any rolls them back.
// Housecarl adds no rule of its own to keep the API reachable.
const probationKey = "probation"
type probation struct {
mu sync.Mutex
pending []int64
deadline time.Time
timer *time.Timer
// restores are nft scripts that bring back tables a takeover deleted.
restores []string
}
// system is the principal of rollbacks.
var system = &auth.Principal{User: &model.User{Username: "housecarl"}, Method: "system"}
type savedProbation struct {
Pending []int64 `json:"pending"`
Deadline time.Time `json:"deadline"`
Restores []string `json:"restores,omitempty"`
}
func (s *Server) saveProbation(ctx context.Context) {
b, _ := json.Marshal(savedProbation{Pending: s.probation.pending, Deadline: s.probation.deadline, Restores: s.probation.restores})
if err := s.store.SetMeta(ctx, probationKey, string(b)); err != nil {
s.log.Error("saving the probation failed", "err", err)
}
}
// afterChange is called after every applied change.
func (s *Server) afterChange(ctx context.Context, p *auth.Principal, res *engine.Result) {
if res == nil || !res.Applied || res.HistoryID == 0 {
return
}
s.startProbation(ctx, p, res.HistoryID, "")
}
// startProbation puts a change (a history entry, or a takeover's restore
// script) on probation, or, through the socket with nothing pending,
// makes it permanent at once.
func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyID int64, restore string) {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
if p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
s.saveBoot()
return
}
if historyID != 0 {
pr.pending = append(pr.pending, historyID)
}
if restore != "" {
pr.restores = append(pr.restores, restore)
}
if p.Method != "socket" || pr.deadline.IsZero() {
d := time.Duration(s.eng.State().Settings.Probation()) * time.Second
if s.probationFor > 0 {
d = s.probationFor
}
pr.deadline = s.nowFn().Add(d)
if pr.timer != nil {
pr.timer.Stop()
}
pr.timer = time.AfterFunc(d, func() { s.rollback("not confirmed in time") })
}
s.saveProbation(ctx)
}
func (s *Server) saveBoot() {
if err := s.fw.SaveBoot(s.eng.State()); err != nil {
s.log.Error("saving the ruleset for the next boot failed", "err", err)
}
}
// probationStatus describes what is pending.
func (s *Server) probationStatus() api.Probation {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
out := api.Probation{Pending: append([]int64{}, pr.pending...), Takeovers: len(pr.restores)}
if len(pr.pending) > 0 || len(pr.restores) > 0 {
out.Deadline = pr.deadline
out.SecondsLeft = int(pr.deadline.Sub(s.nowFn()).Seconds() + 0.5)
if out.SecondsLeft < 0 {
out.SecondsLeft = 0
}
}
return out
}
// confirm makes the pending changes permanent.
func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
n := len(pr.pending) + len(pr.restores)
if n == 0 {
return 0, nil
}
if pr.timer != nil {
pr.timer.Stop()
}
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
s.saveProbation(ctx)
s.saveBoot()
s.eng.Audit(ctx, p, "config.confirm", "", "success", fmt.Sprintf("%d changes confirmed", n))
return n, nil
}
// rollback undoes the pending changes, newest first.
func (s *Server) rollback(why string) {
ctx := context.Background()
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
pending, restores := pr.pending, pr.restores
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
for i := len(restores) - 1; i >= 0; i-- {
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
s.log.Error("bringing back a table a takeover deleted failed", "err", err)
s.eng.Audit(ctx, system, "takeover.rollback", "", "failure", err.Error())
} else {
s.eng.Audit(ctx, system, "takeover.rollback", "", "success", why)
s.log.Warn("brought back a table an unconfirmed takeover deleted", "why", why)
}
}
for i := len(pending) - 1; i >= 0; i-- {
id := pending[i]
if _, err := s.eng.Revert(ctx, system, id, engine.Options{Via: "rollback"}); err != nil {
s.log.Error("rolling back an unconfirmed change failed", "history", id, "err", err)
s.eng.Audit(ctx, system, "config.rollback", fmt.Sprintf("history %d", id), "failure", err.Error())
continue
}
s.eng.Audit(ctx, system, "config.rollback", fmt.Sprintf("history %d", id), "success", why)
s.log.Warn("rolled back an unconfirmed change", "history", id, "why", why)
}
s.saveProbation(ctx)
}
// ResumeProbation rolls back what was pending when housecarld stopped. It
// returns whether anything was rolled back.
func (s *Server) ResumeProbation(ctx context.Context) bool {
v, err := s.store.GetMeta(ctx, probationKey)
if err != nil || v == "" {
return false
}
var saved savedProbation
if json.Unmarshal([]byte(v), &saved) != nil || len(saved.Pending) == 0 && len(saved.Restores) == 0 {
return false
}
s.probation.mu.Lock()
s.probation.pending, s.probation.restores = saved.Pending, saved.Restores
s.probation.mu.Unlock()
s.rollback("housecarld restarted before they were confirmed")
return true
}