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>
187 lines
9.1 KiB
Markdown
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).
|