A change made over the network answers with a one-time confirm_token and closes its connection (HTTP/2: a GOAWAY). The web UI and hc send the token straight back to /confirm, which accepts it only for the newest change, from the same user and API token, the same address, and over a new connection: proof that the client can still reach the API through the new ruleset (an established connection would get through anyway). A change that cuts its client off can't be confirmed so, and rolls back by itself. The setting manual_confirm (hc settings set --auto-confirm off) and hc --no-confirm leave confirming to a person. Changes through the local socket still need no confirmation. e2e: 136 checks, among them a lockout change rolled back without anyone confirming. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
8.2 KiB
Markdown
173 lines
8.2 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. |
|
|
|
|
Undo, 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.
|
|
|
|
## 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).
|