Files
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:16:46 +01:00

187 lines
9.1 KiB
Markdown

# Housecarl's design
Housecarl is a firewall manager with the same shape as
[Jarl](https://gitea.adbraun.lol/adam/jarl) and
[Steward](https://gitea.adbraun.lol/adam/steward): one daemon,
`housecarld`, that serves a REST API and a web UI; one CLI, `hc`, that
talks to that API; SQLite for everything. As Jarl does for BIND, it keeps
a Linux machine's **nftables** ruleset: the database is the source of
truth, and the ruleset is built from it.
## The model
Named objects, and ordered rules that refer to them
([rules.md](rules.md), [nat.md](nat.md)):
* **Zones**: interfaces grouped by what they connect to (`wan`, `lan`,
`wg`). External zones face the internet.
* **Address sets**: IPv4 and IPv6 addresses, prefixes and ranges, mixed.
A **dynamic** set takes entries at run time, each with a timeout that
nftables enforces by itself.
* **Services**: ports of a protocol and ICMP types, by name. Many are
built in (`ssh`, `dns`, `https`, `wireguard`).
* **Rules**, in order, in four chains: `input` (to the firewall),
`forward` (through it), `output` (from it) and `early` (before
connection tracking).
* **NAT**: port forwards (with hairpin NAT and an optional IPv6 pinhole),
source NAT (masquerade or an address), redirects to the firewall.
* **Settings**: the chains' policies, and the **built-ins**, the rules
every firewall needs (loopback, established connections, ICMPv6,
antispoofing, MSS clamping), each of which can be turned off.
IPv4 and IPv6 are handled together: a set holds both, and a rule that
names one becomes one or two nftables rules as needed.
## How a change is made
Every change goes through one engine (`internal/engine`):
1. the request becomes a change to a copy of the configuration;
2. the whole result is validated ([validation.md](validation.md)), with
errors that say what is wrong and how to fix it;
3. the ruleset is rendered (`internal/render`) and checked with
`nft -c -f` (a dry run by nftables itself);
4. it is applied with `nft -f`, which replaces Housecarl's table in one
transaction: the old rules stay in force until the new ones are all
in;
5. the objects, the history entry and the audit record are saved
together. If saving fails, the previous ruleset is applied again.
Dry runs, history with undo, users, sessions and scoped API tokens come
from the same machinery as in Jarl and Steward.
## Reviewing changes: changesets
As in Jarl, a change can be made at once or staged in a **changeset**,
to be reviewed and applied together with others:
* Each change is an **operation** (create, update, delete or move an
object, or change the settings), kept as the API received it.
* Staged in a changeset, an operation is checked as it is added: the
changeset with it must still be valid, and nftables must accept the
ruleset it makes. Nothing is applied.
* The **review** shows the operations, the problems, and the **diff of
the ruleset**: the file that is loaded at boot, now and after.
* **Applying** replays the operations on the configuration as it is then,
as one change: one check, one transaction, one history entry, undone as
a whole. An operation that no longer applies (its object was changed or
deleted since) is named, and can be taken out.
* Then the applied changeset is **on probation** like any change made over
the network: unless it is confirmed, it is rolled back.
| Interface | Default | Why |
|---|---|---|
| Web UI | Review edits: they are collected in a draft changeset, pages show pending rows (green new, yellow changed, struck out deleted), and a bar leads to the Changes page, with the diff and Apply. The switch in the header turns on Live edits, applied at once. | Several related edits, seen together before they are in force. |
| CLI | Immediate: each command is a change of its own. Stage with `-C ID` or `HOUSECARL_CHANGESET`. | Scripts want a clear result per step. |
| API | Immediate unless a changeset is named (`?changeset=ID`, `X-Housecarl-Changeset`). | As the CLI. |
An undo is staged like any edit (in review mode, the web UI's Undo
collects it for review). Imports and takeovers can't be staged; with a
changeset named, they are refused rather than applied.
## Its own table only
Housecarl writes one table, `inet housecarl`, and leaves every other
table alone (Docker's, Incus's, libvirt's). It never flushes the whole
ruleset. The table is replaced in one script:
```
table inet housecarl {}
delete table inet housecarl
table inet housecarl { ... }
```
Two things survive the replacement: the **entries of dynamic sets**
(read before, written back with their remaining timeouts, in the same
script), and the **counters** (housecarld adds the old totals to the
new, so hits don't reset with every change).
Every rule carries a comment `hc:<mark>`, such as `hc:r12` for rule 12,
`hc:f3` for port forward 3, `hc:b-established` for a built-in. Counters
and log lines are traced back to their object through it.
## Not locking yourself out
A change made over the network is applied **on probation** (a changeset
when it is applied, a live edit at once): unless it is
confirmed within two minutes (Settings; `hc confirm`, the banner in the
web UI, `POST /confirm`), it is rolled back by itself, and the rollback is
recorded in the history. A change that cuts you off undoes itself.
* **The client that made a change confirms it by itself** once it
reaches the API again: the change's answer carries a one-time token and
closes its connection (HTTP/2: a GOAWAY), and the web UI and `hc` send
the token straight back. That proves something only on a new
connection, since the built-in that accepts established connections
would let the old one through whatever the change did. So the token is
accepted only from the same user (and API token), the same address, a
different connection, and for the newest change. A change that cuts its
client off can't be confirmed by it, and rolls back. This proves the
API is still reachable, not that SSH or the LAN still work; with the
API, you can fix those. Settings → "a person confirms each change"
(`hc settings set --auto-confirm off`) goes back to confirming by hand;
`hc --no-confirm` leaves one change waiting.
* Changes made through the local socket (`sudo hc …` on the firewall)
need no confirmation: whoever makes them is already on the machine. One
made while others are pending joins them.
* A new change restarts the clock for everything pending.
* If housecarld stops with changes pending, it rolls them back when it
starts.
* Housecarl doesn't force a rule that keeps SSH or the web UI open. You
write the rules that let you in; probation is the safety net.
## At boot
The ruleset is saved to `/var/lib/housecarl/ruleset.nft` whenever it is
confirmed (or changed through the socket). `housecarl-ruleset.service`
loads it before the network comes up, without housecarld, so the
firewall is up even if housecarld can't start. An unconfirmed ruleset is
never saved there: a reboot during probation brings back the last
confirmed one.
It fails closed. A fallback saved beside it (`ruleset-fallback.nft`,
`render.Fallback`) is loaded if it doesn't: no sets, no flowtable,
interfaces by name only, so there is little in it to fail. It keeps
anything new from the external zones out and lets the inside work,
which is as far as a ruleset can go without knowing the rules; a
fallback that let everything in would leave a router open on the
internet, and one that let nothing in would lock out the person who has
to fix it. The boot ruleset has no flowtable either: nftables refuses a
flowtable naming a device that doesn't exist yet (on Debian 13's
kernel), and before the network is up, `ppp0` doesn't. housecarld
watches the kernel's link messages (internal/linkwatch) and applies
again when a flowtable device appears, or comes back as a new interface.
## The drop log
Rules with logging on, and the policies (Settings), send packets to
netlink log group 4171. housecarld listens there and keeps the last 2000
in memory (`hc log`, the Log page). Nothing is written to the kernel log.
## Importing a ruleset
`hc import` turns an existing nftables ruleset into zones, sets,
services, rules and NAT, and `hc takeover` deletes the old table once
Housecarl's replaces it ([import.md](import.md)).
## Working with Steward and Jarl
* **Steward's captive portal** (parked until now): a guest network's zone
lets clients that haven't signed in reach only DNS and the portal, and
sends their web traffic to it. When a guest signs in, Steward adds its
address to a dynamic set for the session's length, with a token that
may only change that set (`sets:write:guests`). Addresses, not MAC
addresses: matching by MAC only works on the guests' own network
segment.
* **Names instead of addresses**: Steward knows every reservation's
address and Jarl every name, so rules could name a device or a subnet.
* **The web UI's certificate** comes through Jarl, as Steward's does
([certificates.md](certificates.md)).
## Not in 0.1
* Interfaces, addresses and routes: Housecarl only runs the firewall.
* iptables rulesets (only nftables can be imported).
* Rules by MAC address, by user, or by DNS name.
* Flow offloading to hardware (the software flowtable is there).