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

+431
View File
@@ -0,0 +1,431 @@
// Package nft runs the nft command: it checks and applies rendered
// rulesets, reads counters and dynamic sets back as JSON, and maps nft's
// error messages to the objects they came from.
//
// The nft command is used rather than a netlink library: it checks a
// ruleset without applying it (nft -c), applies a whole script in one
// transaction, and its errors are readable.
package nft
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"housecarl/internal/model"
"housecarl/internal/render"
)
// Runner runs nft.
type Runner struct {
// Command is how nft is run: ["nft"] (the default), or a wrapper such
// as ["docker", "run", "--rm", "-i", "--cap-add", "NET_ADMIN", "IMAGE",
// "nft"] in tests.
Command []string
}
// Wrapper reads HOUSECARL_NFT, a command line that runs nft (tests point
// it at a container), or returns ["nft"].
func Wrapper() []string {
if v := strings.TrimSpace(os.Getenv("HOUSECARL_NFT")); v != "" {
return strings.Fields(v)
}
return []string{"nft"}
}
func (r *Runner) cmd(ctx context.Context, args ...string) *exec.Cmd {
c := r.Command
if len(c) == 0 {
c = []string{"nft"}
}
return exec.CommandContext(ctx, c[0], append(append([]string{}, c[1:]...), args...)...)
}
// run runs nft with args, script on stdin.
func (r *Runner) run(ctx context.Context, script string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
c := r.cmd(ctx, args...)
if script != "" {
c.Stdin = strings.NewReader(script)
}
var out, errb bytes.Buffer
c.Stdout, c.Stderr = &out, &errb
if err := c.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return out.Bytes(), &Error{Output: msg}
}
return out.Bytes(), nil
}
// Error is nft's complaint.
type Error struct {
Output string
// Object is what the first failing line came from, when known.
Object string
// Message is the first error line, without the file and position.
Message string
}
func (e *Error) Error() string {
if e.Object != "" && e.Message != "" {
return e.Object + ": " + e.Message
}
if e.Message != "" {
return e.Message
}
return e.Output
}
// errLine matches "/dev/stdin:12:3-10: Error: …".
var errLine = regexp.MustCompile(`(?m)^[^:\n]*:(\d+):[0-9-]+: Error: (.*)$`)
// explain fills in Object and Message from nft's output and the rendered
// ruleset.
func explain(err error, rs *render.Ruleset) error {
e, ok := err.(*Error)
if !ok {
return err
}
if m := errLine.FindStringSubmatch(e.Output); m != nil {
line, _ := strconv.Atoi(m[1])
e.Message = strings.TrimSpace(m[2])
e.Object = rs.Object(line)
}
return e
}
// Check has nft check a ruleset without applying it.
func (r *Runner) Check(ctx context.Context, rs *render.Ruleset) error {
_, err := r.run(ctx, rs.Text, "-c", "-f", "/dev/stdin")
return explain(err, rs)
}
// Apply applies a ruleset (one transaction: all or nothing).
func (r *Runner) Apply(ctx context.Context, rs *render.Ruleset) error {
_, err := r.run(ctx, rs.Text, "-f", "/dev/stdin")
return explain(err, rs)
}
// ListTable returns Housecarl's table as nft JSON (nil if it doesn't
// exist).
func (r *Runner) ListTable(ctx context.Context) ([]byte, error) {
out, err := r.run(ctx, "", "-j", "list", "table", "inet", render.Table)
if err != nil {
if e, ok := err.(*Error); ok && strings.Contains(e.Output, "No such file or directory") {
return nil, nil
}
return nil, err
}
return out, nil
}
// ListRuleset returns the whole ruleset as nft JSON (the importer's
// input on this machine).
func (r *Runner) ListRuleset(ctx context.Context) ([]byte, error) {
return r.run(ctx, "", "-j", "list", "ruleset")
}
// TableText returns a table as an nft script that recreates it.
func (r *Runner) TableText(ctx context.Context, family, name string) (string, error) {
out, err := r.run(ctx, "", "list", "table", family, name)
if err != nil {
return "", err
}
return "table " + family + " " + name + " {}\ndelete table " + family + " " + name + "\n" + string(out), nil
}
// ApplyScript applies an nft script (a takeover's rollback).
func (r *Runner) ApplyScript(ctx context.Context, script string) error {
_, err := r.run(ctx, script, "-f", "/dev/stdin")
return err
}
// DeleteTable deletes a table (hc takeover: the ruleset Housecarl
// replaces).
func (r *Runner) DeleteTable(ctx context.Context, family, name string) error {
_, err := r.run(ctx, "", "delete", "table", family, name)
return err
}
// AddElement adds an entry to a dynamic set's family part, with a timeout
// in seconds (0: the set's default).
func (r *Runner) AddElement(ctx context.Context, set, addr string, timeout int) error {
a, err := model.ParseAddress(addr)
if err != nil {
return err
}
t := ""
if timeout > 0 {
t = fmt.Sprintf(" timeout %ds", timeout)
}
_, err = r.run(ctx, fmt.Sprintf("add element inet %s %s { %s%s }\n", render.Table, render.SetName(set, a.IsV6()), a, t), "-f", "/dev/stdin")
return err
}
// DeleteElement removes an entry from a dynamic set.
func (r *Runner) DeleteElement(ctx context.Context, set, addr string) error {
a, err := model.ParseAddress(addr)
if err != nil {
return err
}
_, err = r.run(ctx, fmt.Sprintf("delete element inet %s %s { %s }\n", render.Table, render.SetName(set, a.IsV6()), a), "-f", "/dev/stdin")
return err
}
// ---- reading JSON
// Table is what Housecarl reads back from its table: counters by mark
// and the dynamic sets' entries.
type Table struct {
// Counters by the comment's mark ("r12", "f3", "b-established",
// "p-input"); several nftables rules of one object add up.
Counters map[string]Counter
// Elements by set name (both families together).
Elements map[string][]render.Element
// Since is when the counters started (housecarld's first apply).
Since time.Time
// Tables are all the machine's tables ("inet main"), when the whole
// ruleset was read.
Tables []string
}
// Counter is packets and bytes.
type Counter struct {
Packets uint64 `json:"packets"`
Bytes uint64 `json:"bytes"`
}
type jsonDoc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
type jsonRule struct {
Comment string `json:"comment"`
Expr []json.RawMessage `json:"expr"`
}
type jsonSet struct {
Name string `json:"name"`
Elem []json.RawMessage `json:"elem"`
}
// ParseTable reads `nft -j list table inet housecarl`.
func ParseTable(data []byte) (*Table, error) {
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{}}
if len(data) == 0 {
return t, nil
}
var doc jsonDoc
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("reading nft's JSON: %w", err)
}
for _, item := range doc.Nftables {
if raw, ok := item["rule"]; ok {
var r jsonRule
if json.Unmarshal(raw, &r) != nil || !strings.HasPrefix(r.Comment, "hc:") {
continue
}
mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ")
for _, e := range r.Expr {
var c struct {
Counter *Counter `json:"counter"`
}
if json.Unmarshal(e, &c) == nil && c.Counter != nil {
cur := t.Counters[mark]
cur.Packets += c.Counter.Packets
cur.Bytes += c.Counter.Bytes
t.Counters[mark] = cur
}
}
}
if raw, ok := item["set"]; ok {
var s jsonSet
if json.Unmarshal(raw, &s) != nil {
continue
}
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
continue
}
for _, e := range s.Elem {
if el, ok := parseElem(e); ok {
t.Elements[name] = append(t.Elements[name], el)
}
}
}
}
return t, nil
}
// parseElem reads a set element: "10.0.0.5", or {"elem": {"val":
// "10.0.0.5", "timeout": 1800, "expires": 1799}}.
func parseElem(raw json.RawMessage) (render.Element, bool) {
var s string
if json.Unmarshal(raw, &s) == nil {
return render.Element{Addr: s}, true
}
var w struct {
Elem struct {
Val json.RawMessage `json:"val"`
Expires int `json:"expires"`
} `json:"elem"`
}
if json.Unmarshal(raw, &w) != nil || json.Unmarshal(w.Elem.Val, &s) != nil {
return render.Element{}, false
}
return render.Element{Addr: s, Timeout: w.Elem.Expires}, true
}
// ---- the firewall
// Firewall is the engine's Firewall: it renders configurations and has
// nft check and apply them, carrying dynamic sets' entries over.
type Firewall struct {
Runner *Runner
// Interfaces lists the interfaces that exist now (nil: assume all).
Interfaces func() map[string]bool
// BootFile is where the confirmed ruleset is saved for the boot unit
// (housecarl-ruleset.service; "" for none).
BootFile string
// Replacing the table resets its counters: base keeps the totals of
// the tables replaced since housecarld started.
mu sync.Mutex
base map[string]Counter
since time.Time
}
func (f *Firewall) options() render.Options {
var o render.Options
if f.Interfaces != nil {
o.Interfaces = f.Interfaces()
}
return o
}
// Render builds the ruleset for st as Apply would.
func (f *Firewall) Render(st *model.State) *render.Ruleset { return render.Render(st, f.options()) }
// Check implements engine.Firewall.
func (f *Firewall) Check(ctx context.Context, st *model.State) error {
return f.Runner.Check(ctx, render.Render(st, f.options()))
}
// Apply implements engine.Firewall.
func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
f.mu.Lock()
defer f.mu.Unlock()
opt := f.options()
var old *Table
if data, err := f.Runner.ListTable(ctx); err == nil {
if t, err := ParseTable(data); err == nil {
opt.Elements = t.Elements
old = t
}
}
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
return err
}
if f.base == nil {
f.base, f.since = map[string]Counter{}, time.Now()
}
if old != nil {
for k, c := range old.Counters {
b := f.base[k]
b.Packets += c.Packets
b.Bytes += c.Bytes
f.base[k] = b
}
}
return nil
}
// SaveBoot writes the ruleset of st for the boot unit, atomically.
func (f *Firewall) SaveBoot(st *model.State) error {
if f.BootFile == "" {
return nil
}
rs := render.Render(st, f.options())
tmp := f.BootFile + ".tmp"
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
return err
}
if err := os.WriteFile(tmp, []byte(rs.Text), 0o640); err != nil {
return err
}
return os.Rename(tmp, f.BootFile)
}
// Table reads Housecarl's table back, with counters totalled since
// housecarld started (across reloads).
func (f *Firewall) Table(ctx context.Context) (*Table, error) {
f.mu.Lock()
defer f.mu.Unlock()
data, err := f.Runner.ListTable(ctx)
if err != nil {
return nil, err
}
if data == nil {
return nil, ErrNoTable
}
t, err := ParseTable(data)
if err != nil {
return nil, err
}
for k, b := range f.base {
c := t.Counters[k]
c.Packets += b.Packets
c.Bytes += b.Bytes
t.Counters[k] = c
}
t.Since = f.since
return t, nil
}
// ErrNoTable: Housecarl's table isn't in the ruleset.
var ErrNoTable = errors.New("there is no table inet housecarl: Housecarl's ruleset isn't in force")
// AddElement adds an entry to a dynamic set.
func (f *Firewall) AddElement(ctx context.Context, set, addr string, timeout int) error {
return f.Runner.AddElement(ctx, set, addr, timeout)
}
// DeleteElement removes an entry from a dynamic set.
func (f *Firewall) DeleteElement(ctx context.Context, set, addr string) error {
return f.Runner.DeleteElement(ctx, set, addr)
}
// ListRuleset returns the machine's whole ruleset as JSON.
func (f *Firewall) ListRuleset(ctx context.Context) ([]byte, error) { return f.Runner.ListRuleset(ctx) }
// TableText returns another table as a script that recreates it.
func (f *Firewall) TableText(ctx context.Context, family, name string) (string, error) {
return f.Runner.TableText(ctx, family, name)
}
// ApplyScript applies an nft script.
func (f *Firewall) ApplyScript(ctx context.Context, script string) error {
return f.Runner.ApplyScript(ctx, script)
}
// DeleteTable deletes another table (takeover).
func (f *Firewall) DeleteTable(ctx context.Context, family, name string) error {
if name == render.Table && family == "inet" {
return fmt.Errorf("that is Housecarl's own table")
}
return f.Runner.DeleteTable(ctx, family, name)
}