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

+181
View File
@@ -0,0 +1,181 @@
# The REST API
Everything the web UI and the CLI do goes through this API, under
`/api/v1`. Requests and answers are JSON.
## Authentication
* **API tokens**: `Authorization: Bearer housecarl_...`. Make one in the
web UI (your account), with `hc token create NAME`, or with `housecarld
user token USER NAME` on the server.
* **Sessions**: `POST /auth/login` with `{"username", "password"}` sets a
cookie and returns `csrf_token`. Requests other than GET must send it as
`X-CSRF-Token`.
* **The local socket** (`/run/housecarl/housecarl.sock`): root and
housecarld's own user need nothing; others send a token. Changes made
through it aren't on probation.
Failed logins and token attempts are rate limited per address and per
user name. [permissions.md](permissions.md) has what each call needs.
## Errors
```json
{"error": {"code": "validation", "message": "rule input #31: there is no set trusted",
"problems": [{"severity": "error", "code": "unknown_set", "message": "from: there is no set trusted", "object": "rule input #31"}]}}
```
| Status | Code | |
|---|---|---|
| 400 | `bad_request` | the request can't be read |
| 401 | `unauthenticated`, `session_expired`, `login_failed` | |
| 403 | `forbidden`, `csrf` | |
| 404 | `not_found` | |
| 409 | `conflict` | e.g. undoing a change after the objects changed again |
| 422 | `validation` | the change would make the configuration invalid; `problems` lists why ([validation.md](validation.md)) |
| 422 | `firewall` | nftables refused the ruleset (`nft -c`), or applying it failed; the message has nftables' words. Nothing was changed |
| 429 | `rate_limited` | |
## Changes
Calls that change the configuration answer:
```json
{"applied": true, "history_id": 7,
"changes": [{"kind": "rule", "id": 31, "label": "input #31 (SSH from home)", "after": {...}}],
"warnings": [{"severity": "warning", "code": "set_empty", "message": "...", "object": "..."}],
"object": {...},
"confirm_by": "2026-10-19T10:02:00Z"}
```
* `confirm_by`: the change is on probation, and is rolled back at that
time unless `POST /confirm` comes first. Missing for changes made
through the socket.
* `?dry_run=1` checks the change, with nftables too, and returns what it
would do, with `"applied": false, "dry_run": true`.
* `before` is missing for a create and `after` for a delete.
* `warnings` are the problems the change brings; older ones are in
`/status`.
* `object` is the created or changed object.
Creating answers 201, other changes 200.
## Endpoints
### Status
| | |
|---|---|
| `GET /healthz` | `{"ok": true, "version"}`, without authentication |
| `GET /status` | `build`, `started`, `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
| `GET /ruleset` | the nftables ruleset Housecarl builds, as text |
| `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
| `GET /log[?limit=&after=&q=]` | logged packets, newest first: `time`, `prefix`, `in`, `out`, `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port`, `icmp_type`, `tcp_flags`, `length`, `mark` (the rule's). `after` (a time) for those since; `q` searches |
| `GET /catalog` | built-in services, chains, actions, roles, the log group |
### Settings
`GET /settings`, `PATCH /settings` with any of:
```json
{"input_policy": "drop", "forward_policy": "drop", "output_policy": "accept",
"log_policy_drops": false,
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
"own_prefixes": ["2001:db8:7f3d::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
"flowtable_devices": ["wan", "enp2s0"], "probation_seconds": 120}
```
Lists replace the old ones. `probation_seconds` 0 is the default (120).
### Objects
Zones, sets, services, rules, port forwards, source NAT and redirects
share one pattern, `/{kind}` with kind `zones`, `sets`, `services`,
`rules`, `forwards`, `snats` or `redirects`:
| | |
|---|---|
| `GET /{kind}` | all of them, in order; `GET /rules?chain=input` for one chain; `GET /services` includes the built-in ones |
| `POST /{kind}` | create; rules, forwards and NAT rules go last unless given a `position` |
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards |
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
| `DELETE /{kind}/{ref}` | refused while something uses it |
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
The objects:
```json
{"name": "lan", "interfaces": ["enp2s0", "enp2s0.*"], "external": false, "comment": ""}
{"name": "trusted", "entries": ["10.0.10.0/24", "2001:db8:7f3d:10::/64"], "dynamic": false, "timeout": 0, "comment": ""}
{"name": "jellyfin", "entries": ["tcp/8096", "tcp/8920"], "comment": ""}
{"chain": "input",
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
"sources": ["trusted"], "not_sources": false, "destinations": [], "not_destinations": false,
"services": ["ssh"], "family": "",
"ct_state": ["new"], "ct_status": [], "days": [], "hour_from": "", "hour_to": "",
"limit": {"rate": 3, "per": "minute", "burst": 5, "over": true, "per_source": true}, "conn_limit": 0,
"action": "drop", "reject_with": "", "log": true, "log_prefix": "",
"raw": "", "comment": "", "disabled": false}
{"name": "jellyfin", "protocols": ["tcp"], "ports": "8096", "public_addresses": ["203.0.113.7"], "sources": [],
"to": "10.0.10.57", "to_port": "443", "to6": "2001:db8:7f3d:10::57", "hairpin": true, "hairpin_sources": [],
"comment": "", "disabled": false}
```
A source NAT rule is a match (as a rule's, without the rule's other
fields) with `"to"` (empty: masquerade), a redirect a match with
`"to_port"`. Times are seconds (`timeout`).
### Dynamic sets' entries
| | |
|---|---|
| `GET /sets/{name}/elements` | `[{"address", "timeout"}]`, `timeout` the seconds left |
| `POST /sets/{name}/elements` | `{"address": "10.0.30.17", "timeout": 3600}`; `timeout` 0 is the set's |
| `DELETE /sets/{name}/elements/{address}` | |
These need `sets:read` or `sets:write`, for that set
([permissions.md](permissions.md)); they aren't on probation or in the
history, but are audited.
### Probation
| | |
|---|---|
| `GET /probation` | `{"pending": [history IDs], "takeovers": N, "deadline", "seconds_left"}` |
| `POST /confirm` | keep them: `{"confirmed": N}`; the ruleset is saved for the boot |
| `POST /rollback` | undo them now |
### History, import, takeover
| | |
|---|---|
| `GET /history[?limit=&before=]` | changes, newest first |
| `GET /history/{id}` | one, with each object before and after |
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
| `POST /import/nft` | `{"json": NFT_JSON, "text": "nft -a list ruleset output", "defines": "ruleset file", "table": "inet main", "replace": false}` ([import.md](import.md)); answers with `state` (the imported configuration), `notes` and `raw` too |
| `POST /takeover` | `{"tables": ["inet main"]}`: delete these tables (administrators); on probation over the network |
### Certificate
Administrators only ([certificates.md](certificates.md)).
| | |
|---|---|
| `GET /certificate` | `{"configured", "source": "acme"\|"file"\|"none", "status": {"domain", "directory", "staging", "state": "none"\|"ok"\|"working"\|"error", "not_before", "not_after", "issuer", "renew_at", "last_error", "last_attempt", "next_attempt", "check"}}` |
| `POST /certificate/check` | run the check now (up to a few minutes); answers `{"time", "domain", "ok", "steps": [{"name", "status", "detail", "queries"}]}` |
| `POST /certificate/renew` | get a new certificate now, in the background (202); follow it with `GET /certificate` |
### Users, tokens, audit
| | |
|---|---|
| `POST /auth/login`, `POST /auth/logout`, `GET /auth/whoami` | `whoami` has `token_id` when the caller uses a token |
| `POST /me/password` | `{"current", "password"}` |
| `GET /me/tokens`, `POST /me/tokens` | `{"name", "role", "grants", "expires_in"}`; the answer's `secret` is shown once |
| `GET /tokens`, `DELETE /tokens/{id}` | all tokens (administrators), revoke |
| `POST /tokens/{id}/rotate` | a new secret: `{"grace": "1h", "expires_in": "90d", "no_expiry": false}`, all optional; answers with `secret` and `previous_secret_valid_until` |
| `GET`, `POST /users`; `PATCH`, `DELETE /users/{id}` | administrators; `{"username", "password", "admin", "role", "grants", "disabled", "comment"}` |
| `GET`, `POST /users/{id}/tokens` | |
| `GET /audit[?user=&action=&result=&q=&before=&limit=]` | |
+120
View File
@@ -0,0 +1,120 @@
# The web UI's certificate
Housecarl can serve its web UI and API over HTTPS in two ways:
* **A certificate you manage**: `tls.cert_file` and `tls.key_file` in
`housecarl.yaml`. housecarld rereads them when they change
([configuration.md](configuration.md#https)).
* **A certificate it gets itself** from Let's Encrypt (or another ACME CA),
with Jarl publishing the DNS-01 challenge. That's this page.
With DNS-01 the web UI doesn't need to be reachable from the internet.
`fw.adbraun.lol` can point at a private address and the certificate
still works on your network. Only the challenge record has to be visible
on the internet, and Jarl puts it in every view that has your zone.
## Setting it up
1. **Connect Housecarl to Jarl** with a user and a token that may only write
Housecarl's own challenge record: [jarl.md](jarl.md).
2. **Give your web UI a name in DNS**, such as `fw.adbraun.lol` with the
Housecarl server's address, in Jarl. It is the name you will type in the
browser.
3. **Configure the certificate** in `/etc/housecarl/housecarl.yaml`:
```yaml
listen: ["10.0.10.2:8070"] # an address your browser can reach
tls:
acme:
domain: fw.adbraun.lol
email: you@adbraun.lol # Let's Encrypt writes here if something is wrong
directory: letsencrypt-staging # first; see below
agree_tos: true # you accept Let's Encrypt's terms of service
jarl:
server: https://dns.adbraun.lol:8053
token_file: /etc/housecarl/jarl.token
```
4. **Restart housecarld** (`sudo systemctl restart housecarl`), and check:
```sh
sudo hc cert check
```
5. **Watch it get the certificate:** `sudo hc cert`, or Server →
Certificate in the web UI. Then open `https://fw.adbraun.lol:8070/`.
6. **Switch to the real CA.** The staging certificate works, but browsers
warn about it, because Let's Encrypt's staging service isn't trusted.
It's there to prove everything works without touching Let's Encrypt's
rate limits. Once it does, set `directory: letsencrypt`, restart, and
Housecarl gets a trusted certificate right away.
Until the first certificate arrives, the web UI is served over plain
HTTP. housecarld switches to HTTPS on the same port without a restart. From
then on, plain HTTP is redirected to HTTPS, except from the server itself
(`127.0.0.1`), so tools there keep working.
## Checking
Before every certificate order, and whenever you ask (`hc cert
check`, or Test in the web UI), Housecarl checks the whole path without
asking the CA. Let's Encrypt allows only 5 failed validations per name
per hour, so a broken setup shouldn't find out the hard way.
| Step | What it checks |
|---|---|
| Jarl | Jarl answers at `jarl.server`, and the token is valid (it says whose it is) |
| Zone | a primary zone the token can see holds `_acme-challenge.<domain>`, and in which views |
| Permission | the token may write the TXT record there (Jarl checks it as a dry run) |
| Write | a test record with a random value is written through Jarl, which loads it into named |
| Name servers | each of the zone's public name servers serves it, asked directly as the CA's resolvers will. A name server passes if any of its addresses does (`tls.acme.check_propagation`) |
| Public resolvers | the resolvers in `tls.acme.resolvers` (1.1.1.1, 9.9.9.9, 8.8.8.8) see it. If the name servers do but a resolver doesn't, it is only a warning: it may be holding an earlier answer |
| Clean up | the test record is removed |
Each step says what went wrong and what to do. For example:
```
ok Jarl the token is housecarl's, on Jarl v0.6.1
ok Zone adbraun.lol in views internal, external
FAIL Permission the token may not write the challenge record (...); give it the grant:
records:write zones=adbraun.lol names=_acme-challenge.fw.adbraun.lol types=TXT
```
The checks wait up to 90 seconds for the record to appear. A failing step
lists the DNS questions it asked, as `dig` commands, so you can ask them
yourself.
If the challenge name is a CNAME into another zone, the check follows it,
as Let's Encrypt does.
## Renewal
A certificate is renewed when a third of its lifetime is left: 30 days
before the end for Let's Encrypt's 90-day certificates, 2 days for 6-day
ones. housecarld looks twice a day. The check runs first every time, and
the new certificate is used at once, without a restart.
If renewing fails, the old certificate keeps being served, and housecarld
tries again after 1 hour, then 3, 6 and every 12. The dashboard shows a
problem (`certificate_failed`), and `certificate_expiring` once less than
14 days are left. `hc cert renew` (Renew now in the web UI) tries
again at once.
The ACME account and the certificate, with its key, are kept in Housecarl's
database, so backups include them.
## Settings
All under `tls.acme` in `housecarl.yaml` ([configuration.md](configuration.md)):
| Setting | Default | |
|---|---|---|
| `domain` | none (off) | the name on the certificate |
| `email` | none | the account's contact address |
| `directory` | `letsencrypt-staging` | `letsencrypt`, `letsencrypt-staging`, or an ACME directory URL (a private CA such as step-ca) |
| `agree_tos` | `false` | accept the CA's terms of service; no account is made without it |
| `resolvers` | `1.1.1.1:53`, `9.9.9.9:53`, `8.8.8.8:53` | public resolvers, to see the challenge as the CA does |
| `check_propagation` | `true` | also ask each of the zone's name servers directly |
| `ca_bundle` | none | extra CAs to trust for the ACME server itself |
`tls.cert_file` and `tls.acme` can't both be set.
+193
View File
@@ -0,0 +1,193 @@
# The hc CLI
`hc` talks to housecarld's REST API. `hc help COMMAND` describes each
command and its flags.
## Connecting
* **On the firewall**, with nothing configured, the CLI uses
housecarld's local socket, `/run/housecarl/housecarl.sock`. Root and
the `housecarl` user are administrators there without a token, and
their changes need no confirmation: `sudo hc status`.
* **From elsewhere**, give the server's URL and an API token:
```sh
hc config set server https://fw.example.com:8070
hc config set token # asks for it
```
The settings are in `~/.config/housecarl/config.yaml` (on macOS
`~/Library/Application Support/housecarl/config.yaml`, on Windows
`%AppData%\housecarl\config.yaml`).
| Flag | Environment | |
|---|---|---|
| `-s, --server URL` | `HOUSECARL_SERVER` | the server; `local` for the local socket |
| `--token TOKEN` or `@FILE` | `HOUSECARL_TOKEN` | the API token |
| `--config FILE` | `HOUSECARL_CLI_CONFIG` | the CLI's configuration file |
| | `HOUSECARL_SOCKET` | the local socket's path |
| `--insecure`, `--ca-cert FILE` | | TLS: skip the check, or trust another CA |
| `-n, --dry-run` | | check and show a change without making it |
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
| `-q, --quiet` | | print only errors (and the IDs of new objects) |
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not
found, 5 conflict, 6 invalid or refused by validation or nftables, 7
server error.
## Changes
Every change is checked as a whole, then by nftables, before it is put
in force. A refused change says why, with the problem's code
([validation.md](validation.md)). An applied one says its history entry
and any new warnings. Over the network, it is on probation:
```
$ hc rule add input --from trusted --service ssh --comment "SSH from home"
created rule input #31 (from trusted · ssh)
applied on probation (history 12): confirm with 'hc confirm' within 2m0s, or it is rolled back
$ hc confirm
confirmed 1 change; it is kept and loaded at boot
```
Through the socket the change is kept at once:
```
applied and saved (history 12; undo with 'hc history undo 12')
```
`-n` shows what would change without changing it; nftables checks it
too.
## Commands
Objects are named by ID or, for zones, sets, services and port forwards,
by name.
### status and the ruleset
| Command | |
|---|---|
| `status` | the ruleset (in force or not), changes on probation, problems, interfaces, other tables |
| `interfaces` | the machine's interfaces, their addresses and zones |
| `ruleset` | the nftables ruleset Housecarl builds |
| `counters` | the hits of every rule, NAT rule, built-in and policy, by mark (`r12`, `f3`, `b-established`, `p-input`) |
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
### zone, set, service
| Command | |
|---|---|
| `zone list`, `zone show ZONE` | |
| `zone add NAME -i IFACE... [--external] [--comment C]` | |
| `zone update ZONE [-i IFACE]... [--add IFACE] [--remove IFACE] [--external[=false]] [--name N] [--comment C]` | `-i` replaces the interfaces |
| `zone delete ZONE` | refused while rules use it |
| `set list`, `set show SET` | |
| `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | |
| `set update SET [--entry E]... [--add E] [--remove E] [--timeout] [--name] [--comment]` | `--entry` replaces the entries |
| `set entries SET`, `set entries add SET ADDRESS [--timeout 1h]`, `set entries remove SET ADDRESS` | a dynamic set's entries |
| `set delete SET` | |
| `service list`, `service show SERVICE` | with the built-in ones |
| `service add NAME ENTRY... [--comment C]` | entries `tcp/22`, `udp/1000-2000`, `icmp/echo-request`, `icmpv6/packet-too-big` |
| `service update SERVICE`, `service delete SERVICE` | |
See [rules.md](rules.md).
### rule
| Command | |
|---|---|
| `rule list [CHAIN]` | in order, with their hits |
| `rule show ID` | |
| `rule add CHAIN [MATCH] [-a ACTION] [--first\|--before ID\|--after ID]` | at the end of its chain unless placed |
| `rule update ID [flags]` | only the flags given |
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
| `rule enable\|disable ID` | |
| `rule delete ID` | |
The match flags (`rule add`, `snat add`, `redirect add`):
| | |
|---|---|
| `--in ZONE`, `--out ZONE` (`--not-in`, `--not-out`) | zones |
| `--from`, `--to` (`--not-from`, `--not-to`) | sets, addresses, prefixes, ranges |
| `--service` | services or entries |
| `--family ipv4\|ipv6` | |
And for rules: `--state`, `--status`, `--days`, `--hours`, `--limit
10/minute[,burst=N][,over][,per-source]`, `--conn-limit N`, `-a
accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`,
`--log-prefix`, `--raw RULE`, `--comment`, `--disabled`
([rules.md](rules.md#rules)).
### forward, snat, redirect
| Command | |
|---|---|
| `forward list`, `forward show FORWARD` | port forwards, with their hits |
| `forward add PORTS --to HOST[:PORT] [--proto tcp,udp] [--public ADDR]... [--from SRC]... [--hairpin] [--hairpin-from SRC]... [--to6 HOST] [--name N] [--comment C]` | |
| `forward update FORWARD [flags]`, `forward delete FORWARD` | |
| `snat list`, `snat add [MATCH] [--snat-to ADDR]`, `snat update ID`, `snat delete ID` | source NAT, in order; masquerade without `--snat-to` |
| `redirect list`, `redirect add [MATCH] [--to-port PORT]`, `redirect update ID`, `redirect delete ID` | |
See [nat.md](nat.md).
### settings
| Command | |
|---|---|
| `settings show` | |
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m]` | lists given replace the old ones |
### probation, history, import
| Command | |
|---|---|
| `confirm` | keep the changes on probation; the ruleset is saved for the boot |
| `rollback` | undo the changes on probation now |
| `history list [--limit N]` | changes, newest first |
| `history show ID` | each object before and after |
| `history undo ID` | apply the change backwards (refused if something it touched has changed since) |
| `import [FILE\|-] [--text FILE] [--defines FILE] [--table T] [--replace]` | [import.md](import.md) |
| `takeover TABLE... [-y]` | delete tables Housecarl's ruleset replaces (administrators) |
### cert
Administrators only ([certificates.md](certificates.md)).
| Command | |
|---|---|
| `cert` | the certificate: name, CA, validity, renewal, the last check |
| `cert check` | check that the challenge can be published through Jarl and seen, without the CA (exit code 1 if it can't) |
| `cert renew [-w]` | get a new certificate now; `-w` waits and shows the result |
### users, tokens, audit
| Command | |
|---|---|
| `whoami` | who you are to the server, and what you may do |
| `user list\|add\|update\|delete` | users (administrators); `user add NAME --role operator` |
| `token list [--all]`, `token create NAME [--role R] [--grant G]... [--expires 90d] [--user U] [--save]`, `token revoke ID` | API tokens |
| `token rotate ID\|--current [--grace 1h] [--expires 90d\|never] [--save]` | a new secret for a token, the old one working for the grace period |
| `password` | change your password |
| `audit [--user U] [--action PREFIX] [--search TEXT]` | the audit log |
| `config show`, `config set server\|token\|insecure\|ca_cert [VALUE]` | this CLI's settings |
[permissions.md](permissions.md) explains roles and grants.
## housecarld
The daemon's own commands work on the database directly, as root or the
`housecarl` user (`sudo -u housecarl housecarld ...`):
| Command | |
|---|---|
| `housecarld serve` | run (the systemd unit does) |
| `housecarld init [-u NAME] [--token]` | create the database and the first administrator (asks for the name on a terminal) |
| `housecarld user add NAME [--admin]`, `user password NAME`, `user token NAME TOKEN-NAME` | when the API can't be used |
| `housecarld check` | check the configuration against this machine's interfaces |
| `housecarld backup [FILE]`, `housecarld restore FILE` | [operations.md](operations.md) |
| `housecarld config [--example]` | the settings in use, or a commented example |
| `housecarld version` | |
All take `-c FILE` for another configuration file.
+52
View File
@@ -0,0 +1,52 @@
# Configuration file
`/etc/housecarl/housecarl.yaml` says how housecarld runs. The firewall's
configuration (zones, sets, rules, NAT, settings) isn't here; it is in
the database, changed with the CLI, the API or the web UI.
`housecarld config --example` prints a commented file with the defaults.
Every setting is optional; unknown settings are an error. housecarld
reads the file when it starts.
| Setting | Default | |
|---|---|---|
| `listen` | `["127.0.0.1:8070"]` | addresses of the web UI and API (`host:port`). Keep them on localhost or a management network, and write an input rule that lets you reach them |
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; housecarld rereads it when the file changes, or on `systemctl reload housecarl` |
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server for the certificate's challenge, and its token ([jarl.md](jarl.md)) |
| `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot |
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
| `socket` | `/run/housecarl/housecarl.sock` | the local API socket; `""` turns it off |
| `database` | `/var/lib/housecarl/housecarl.db` | the SQLite database: configuration, history, users, audit log |
| `log.level` | `info` | `debug` (every HTTP request), `info`, `warn` or `error` |
| `log.format` | `text` | `text` or `json` |
| `session.lifetime` | `12h` | how long a web UI login lasts |
| `session.idle_timeout` | `2h` | logged out after this long without use |
| `session.cookie_secure` | `auto` | `auto` (secure over HTTPS), `always` or `never` |
| `backup.dir` | `/var/backups/housecarl` | where `housecarld backup` writes |
| `backup.retention` | `30d` | backups older than this are deleted… |
| `backup.keep_at_least` | `7` | …keeping at least this many |
Durations are written `15s`, `12h` or `30d`.
The probation's length is a firewall setting, not here: `hc settings set
--probation 5m`.
## HTTPS
Over plain HTTP, passwords and tokens cross the network in the clear;
housecarld warns when it listens on a non-loopback address without TLS.
Let it get its own certificate through Jarl ([certificates.md](certificates.md)),
or give it one:
```yaml
listen: ["10.0.10.1:8070"]
tls:
cert_file: /etc/housecarl/tls/fullchain.pem
key_file: /etc/housecarl/tls/privkey.pem
```
Or keep it on `127.0.0.1` behind a reverse proxy that terminates TLS, and
list the proxy in `trusted_proxies`. With a certificate, the same port
answers HTTPS and redirects plain HTTP to it, except from the firewall
itself.
+100 -56
View File
@@ -8,79 +8,123 @@ 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.
This page is the starting sketch. Nothing here is built yet; it records
what was decided and what is still open.
## 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
As in Jarl, every change goes through one engine:
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, with errors that say what is wrong and
how to fix it;
3. the ruleset is rendered, and checked with `nft -c -f` (a dry run by
nftables itself);
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 change, its history entry and the audit record are saved together.
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.
### Not locking yourself out
A firewall managed over the network can cut off its own manager. Housecarl
should guard against that from the start:
* **Confirm or roll back.** A risky change (one touching the rules for the
machine itself) is applied on probation: unless it is confirmed within a
minute or two, from the web UI or `hc confirm`, the previous ruleset comes
back by itself. Network equipment calls this "commit confirmed".
* **A management rule that can't be dropped by accident**: the API and SSH
stay reachable from the management network unless that rule is removed
on purpose.
from the same machinery as in Jarl and Steward.
## Its own table only
Housecarl writes one nftables table of its own (`inet housecarl`) and
leaves every other table alone, such as Docker's or libvirt's. It never
flushes the whole ruleset.
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:
## Concepts (draft)
```
table inet housecarl {}
delete table inet housecarl
table inet housecarl { ... }
```
* **Zones**: interfaces or VLANs grouped by trust (`lan`, `iot`, `guest`,
`wan`), and the default policy between each pair.
* **Address sets**: named sets of addresses or prefixes, which rules refer
to. An entry may have a **timeout**, which nftables enforces by itself, so
an entry expires even if housecarld is down. Other programs can be given
a token that may only change one set.
* **Rules** between zones: allow or drop, by set, port and protocol, with
logging and counters.
* **NAT**: masquerading out of the WAN zone, and port forwards.
* **Services**: names for ports (`ssh`, `https`, `dns`).
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**: 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.
* 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 Housecarl exists): 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 set (`guest-signed-in`) for the session's
length, with a token that may only change that set. Addresses, not MAC
* **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 and
lease's address, so rules could name a device ("the printer") or a
subnet; Jarl knows the names in DNS.
* **Steward's deny lists** could also be enforced here, so a denied device
with an address set by hand is still blocked.
* **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)).
## Open questions
## Not in 0.1
* IPv6 from the start, or IPv4 first as Steward did?
* Import an existing nftables (or iptables) ruleset, as Jarl imports a BIND
configuration?
* Connection tracking defaults: established and related traffic allowed
everywhere unless a rule says otherwise?
* Does Housecarl also run the router's interfaces and routes, or only the
firewall? (Only the firewall, for now.)
* Rate limits and flow offloading: later.
* 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).
+137
View File
@@ -0,0 +1,137 @@
# Developing Housecarl
## Layout
```
cmd/housecarld/ the daemon: serve, init, user, check, backup, restore, config
cmd/hc/ the CLI
internal/
model/ the configuration (State: settings, zones, sets, services, rules, NAT), users, history
validate/ whole-configuration checks with stable codes (docs/validation.md)
render/ State → the nftables ruleset text, with each line's object
nft/ runs nft: check, apply (keeping dynamic set entries and counters), list, the boot file
nflog/ reads logged packets from netlink log group 4171
importer/ nft -j list ruleset → State, with notes and raw rules (docs/import.md)
hostif/ the machine's interfaces
store/ SQLite (modernc.org/sqlite, no cgo): configuration, history, users, tokens, sessions, audit
engine/ configuration changes: edit a copy, diff, validate, check and apply, save with history, undo
server/ the HTTP API and the web UI, authentication, the local socket, probation, the log ring
api/, client/ JSON types and the Go client shared by server and CLI
auth/ passwords (argon2id), tokens, sessions, grants and roles
certs/, jarl/ the web UI's certificate through Jarl (docs/certificates.md)
config/ housecarld's YAML configuration
version/ what a build reports
web/static/ the web UI: plain ES modules, no build step, embedded with go:embed
deploy/ systemd units, sysusers.d and tmpfiles.d files, Debian package scripts
scripts/e2e.sh end-to-end test with real traffic through a router container
scripts/mkdeb.sh the Debian packages (make deb)
scripts/logo.py the web UI's logo from artwork/logo.svg
scripts/banner.py the README's banner from the web UI's logo and wordmark
```
[design.md](design.md) describes how the pieces fit.
## Building and testing
```sh
make # bin/housecarld, bin/hc, bash completion
make test # go vet, gofmt check, go test ./...
make e2e # scripts/e2e.sh: needs Docker
make deb # dist/housecarl_<version>_<arch>.deb and housecarl-cli_...
```
**Never apply a test ruleset to your own machine's nftables.** Tests that
need the real `nft` run it in a container.
**Unit tests** need no nftables: the API tests (`internal/server`) use a
fake firewall. The render tests compare the ruleset
with what is expected, line by line, and the importer tests import the
maintainer's router ruleset (`internal/importer/testdata`) and check the
result.
**Real nftables**: with `HOUSECARL_NFT` set to a command that runs nft
somewhere harmless, the render, nft and importer tests also have nft
check (and apply) their rulesets. A `debian:trixie` image with nftables
1.1.3 (Debian 13's) is the reference:
```sh
make e2e # makes the housecarl-nft image, if missing
export HOUSECARL_NFT="docker run --rm -i --cap-add NET_ADMIN housecarl-nft nft"
export HOUSECARL_NFT_IMAGE=housecarl-nft
make test
```
Newer nftables accepts things 1.1.3 doesn't (a meter with a timeout for
`ct count`, hour ranges past midnight): keep to what 1.1.3 takes.
**The e2e test** builds a small network in Docker from the same image:
* a router container (housecarld, the `hc` CLI) on three networks named
`wan`, `enp2s0` and `wg0` inside it, IPv4 and IPv6;
* "the internet" on the WAN side, a LAN client, and a "jellyfin" host.
It imports a copy of the maintainer's ruleset (lab addresses), takes
over the old table, and checks with real traffic: port forwards with
hairpin NAT and the IPv6 pinhole, masquerading, antispoofing, the drop
log, a lockout change that rolls back by itself, a restart, the boot
ruleset after a flush, and that other tables are left alone. It makes
the `housecarl-nft` image itself if it is missing.
`KEEP=1` leaves the lab running (the web UI on `10.88.10.1:8070`);
`DOCKER_HOST` points at another Docker daemon. Root isn't needed on the
host.
The systemd sandbox (capabilities, the boot unit) is only tested by
installing the package in a container that runs systemd
(`--privileged --cgroupns=host /sbin/init`).
## Certificates
As in Steward: `internal/certs` has unit tests with a fake Jarl and an
in-process DNS server; a real order is tested against Jarl's e2e
environment and Pebble (Steward's docs/development.md has the steps).
## The web UI
Plain JavaScript modules under `internal/web/static/`, built with the
same helpers as Jarl's and Steward's:
* All DOM goes through `h()` in `ui.js`, never `innerHTML`.
* The Content-Security-Policy allows only the app's own scripts and
styles, so no inline styles. Use a class in `style.css`, or set
`el.style.prop`.
* The files are embedded, so rebuild housecarld after changing them.
There are no JavaScript tests: bundle `assets/app.js` with esbuild to
catch syntax and import errors, and look at the pages in a browser
against the e2e lab.
## Conventions
* Every user-visible change goes into `CHANGELOG.md` under "Unreleased",
with the docs updated in the same change.
* Errors say what is wrong and how to fix it, naming the object.
* New validation rules get a stable code, a test, and a row in
[validation.md](validation.md).
* Configuration changes go through the engine, never straight to the
store, so they are validated, checked by nftables, recorded and
undoable.
* Schema changes append a migration to `internal/store/store.go`; never
edit a shipped one.
* `gofmt` and `go vet` clean (`make test`).
## Releasing
As for Jarl and Steward:
1. Move "Unreleased" in `CHANGELOG.md` under `## [<version>] - <date>`.
A release candidate may leave it there.
2. Put the version in `VERSION`, and update the README's status line.
3. Run `make test && make e2e`.
4. Commit as "Release X", tag it `git tag -a vX -m "Housecarl X"`, and push.
The tag starts `.gitea/workflows/release.yml`, which checks the tag
against `VERSION` and the CHANGELOG, runs the tests, builds the tarballs,
the CLI for each OS and the `.deb`s, and publishes them
(`scripts/gitea-release.sh`). It needs the repository secret
`RELEASE_TOKEN` (`write:repository`, `write:package`).
+120
View File
@@ -0,0 +1,120 @@
# Importing an nftables ruleset
`hc import` turns the ruleset you have into Housecarl's configuration,
and `hc takeover` deletes the old table once Housecarl's replaces it.
Until then both apply.
## 1. Look first
On the firewall, as root:
```sh
sudo hc import --defines /etc/nftables.conf -n
```
`hc` runs `nft -j list ruleset` (and `nft -a list ruleset`, for the text
of rules it can't translate) and sends both to housecarld. `-n` (dry run)
shows what it would make, without saving anything: zones, sets,
services, rules, port forwards and NAT, then **notes** on every guess it
made, and the rules it **kept raw**.
`--defines` reads your ruleset file's `define` lines, so sets get your
names (`define trusted_ips = { ... }` becomes the set `trusted_ips`).
Without it, sets are named after what they hold. Interfaces named by a
define name their zone too (`define wan_interface = "ppp0"` makes the
zone `wan`); otherwise a zone is named after its interface.
From another machine, give the JSON as a file, and the text with
`--text`:
```sh
ssh fw 'sudo nft -j list ruleset' > ruleset.json
ssh fw 'sudo nft -a list ruleset' > ruleset.txt
hc import ruleset.json --text ruleset.txt --defines nftables.conf -n
```
With several tables, `--table "inet main"` picks one; by default every
table but Housecarl's is read. The web UI has the same under Server →
Import a ruleset.
## What becomes what
* **Interfaces** become zones. Zones are marked external when the rules
show it: MSS clamping on the way out, DHCPv6 client replies, ping
accepted from everywhere else.
* **Sets and defines** of addresses become address sets; port lists
become services.
* **Jumps and gotos** to your own chains are flattened: the jump's
condition is added to each rule of the chain, in place.
* **Rules** keep their order. A rule that is a built-in (loopback,
established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client
rule, antispoofing) becomes that built-in only when it sits where the
built-in goes, at the head of every chain it covers. Otherwise it stays
a rule where it was, so the ruleset does what it did. Built-ins your
ruleset doesn't have are turned off.
* **DNAT** rules become port forwards, TCP and UDP of the same port
merged into one. The forward-chain accepts that let them through
become part of the port forward, and a masquerade for inside sources
to the same host becomes its hairpin. An IPv6 accept to the same port
becomes the forward's IPv6 pinhole when there is only one candidate.
* **Masquerade and SNAT** become source NAT; **redirect** becomes a
redirect.
* **Policies** become the chains' policies.
* **Anything else** becomes a raw rule with its original text, in place.
A raw rule naming a set of the old table needs the new set name:
Housecarl's are `NAME_v4` and `NAME_v6`, and a note says so.
Chains on hooks Housecarl doesn't manage (ingress, a bridge table) are
left out, with a note.
## 2. Import
```sh
sudo hc import --defines /etc/nftables.conf
sudo hc ruleset # what Housecarl now applies
sudo hc status # problems, and the other tables
```
Housecarl's table is now in force **alongside** the old one: a packet
must be accepted by both. Nothing should change for your traffic. If
Housecarl already has a configuration, `--replace` replaces it.
Imported over the network (not through the local socket), the import is
on probation like any change: confirm it.
## 3. Take over
Once the rules look right (and their hits in the web UI agree), delete
the old table:
```sh
sudo hc takeover "inet main"
```
From now on only Housecarl's rules apply. Over the network, this is on
probation too: unless confirmed, the old table comes back.
## 4. The boot
Housecarl's ruleset is loaded at boot by `housecarl-ruleset.service`.
Debian's `nftables.service` loads `/etc/nftables.conf`, which usually
starts with `flush ruleset`: that would delete Housecarl's table, or
bring the old one back. Retire it:
```sh
sudo systemctl disable nftables.service
sudo mv /etc/nftables.conf /etc/nftables.conf.before-housecarl
```
`housecarl-ruleset.service` runs after `nftables.service` in any case,
so a leftover `flush ruleset` can't undo it. Docker, Incus and libvirt
keep adding their own tables as before.
## Checking the result
* `hc rule list input` (and `forward`) side by side with
`nft -a list table inet main`: the same rules, in the same order, with
built-ins above.
* The notes: each says what to check.
* `hc counters` after a while: a rule with no hits that had hits before
is worth a look.
+138
View File
@@ -0,0 +1,138 @@
# Installing Housecarl
Housecarl runs on Linux with nftables 1.0 or newer. It is made for and
tested on Debian 13 (nftables 1.1.3). The CLI also runs on macOS and
Windows.
## From the package registry
The release workflow publishes Debian packages to the Gitea registry,
`stable` for releases and `testing` for release candidates. Jarl's and
Steward's packages come from the same registry. If apt already has it,
`sudo apt install housecarl` is all it takes. Otherwise add it:
```sh
sudo install -d /etc/apt/keyrings
sudo curl -fsSL https://gitea.adbraun.lol/api/packages/adam/debian/repository.key -o /etc/apt/keyrings/gitea-adam.asc
echo "deb [signed-by=/etc/apt/keyrings/gitea-adam.asc] https://gitea.adbraun.lol/api/packages/adam/debian stable main" \
| sudo tee /etc/apt/sources.list.d/gitea-adam.list
sudo apt update
sudo apt install housecarl
```
For release candidates too, add the `testing` distribution: the same
`deb` line with `testing` instead of `stable`.
There are two packages:
* **`housecarl`** is the server: `/usr/bin/housecarld`, the systemd units
`housecarl.service`, `housecarl-ruleset.service` (the ruleset at boot),
`housecarl-backup.service` and `housecarl-backup.timer`, and the
`housecarl` user and directories. It creates
`/etc/housecarl/housecarl.yaml` on the first install and never touches
it again. It depends on `housecarl-cli` and `nftables`.
* **`housecarl-cli`** is the CLI, `/usr/bin/hc`. It can be installed
alone on any machine to manage Housecarl remotely.
Installing changes nothing in the firewall: housecarld applies a ruleset
only once it has a configuration.
The release page also has `.deb` files, tarballs and the CLI for Linux,
macOS and Windows.
## From source
```sh
make # bin/housecarld, bin/hc (Go, as in go.mod)
sudo make install # /usr/local/bin, units in /etc/systemd/system
sudo systemd-sysusers housecarl.conf && sudo systemd-tmpfiles --create housecarl.conf
sudo chgrp housecarl /etc/housecarl /etc/housecarl/housecarl.yaml && sudo systemctl daemon-reload
```
`make deb` builds the packages (`DEB_ARCH=arm64` for another architecture).
## First steps
Do this from a console, or over SSH with a way back in if it goes wrong:
you are changing the firewall you are connected through.
1. **Settings** are in `/etc/housecarl/housecarl.yaml`
([configuration.md](configuration.md)). By default the web UI and API
listen on `127.0.0.1:8070`. To reach them from your network, add an
address there, ideally with TLS or behind a TLS reverse proxy.
2. **Create the database and the first administrator:**
```sh
sudo -u housecarl housecarld init
```
It asks for the administrator's name (`admin` if you just press Enter;
your own name is better) and password.
3. **Start Housecarl:**
```sh
sudo systemctl enable --now housecarl
```
Until it has a configuration, housecarld applies nothing: the machine
stays as open (or as closed) as it was.
4. **Import your ruleset**, if you have one ([import.md](import.md)):
```sh
sudo hc import --defines /etc/nftables.conf -n # look
sudo hc import --defines /etc/nftables.conf # import
sudo hc takeover "inet main"
sudo systemctl disable nftables.service
```
**Or start from scratch**, through the local socket (no probation).
The first change puts the ruleset in force, and the input and forward
policies drop what no rule accepts, so let yourself in first:
```sh
sudo hc set add admins 10.0.10.0/24
sudo hc rule add input --from admins --service ssh
sudo hc rule add input --from admins --service tcp/8070 --comment "Housecarl"
sudo hc zone add wan --interface ppp0 --external
sudo hc zone add lan --interface enp2s0
sudo hc rule add input --in lan --service dns --service dhcp
sudo hc rule add forward --in lan --out wan
sudo hc snat add --out wan
sudo hc settings set --builtin mss_clamp=on --own-prefix 2001:db8:7f3d::/48
sudo hc status
```
Housecarl doesn't add a rule that keeps SSH or the web UI open: you
write it. Connections already open (your SSH session) carry on, since
established connections are accepted.
5. **Open the web UI** on an address in `listen`, and log in.
## From another machine
Install `housecarl-cli`, make a token, and set the server:
```sh
sudo hc token create laptop # on the firewall; prints the token once
hc config set server https://fw.example.com:8070
hc config set token # paste it
hc status
```
Changes from there are on probation: `hc confirm` keeps them.
## Upgrading
`apt upgrade` restarts a running housecarld, which applies the ruleset
again (counters keep counting) and upgrades its database when it starts.
The firewall stays up during the upgrade: the ruleset belongs to the
kernel, not to housecarld. Take a backup first if you like
(`sudo -u housecarl housecarld backup`); the daily timer keeps 30 days of
them.
## Removing
`apt remove housecarl` stops housecarld and disables the boot unit. The
ruleset stays in force until the next boot, or until you delete it:
`sudo nft delete table inet housecarl`. A purge keeps the database and
the backups; remove `/var/lib/housecarl` and `/var/backups/housecarl` by
hand if you no longer need them.
+85
View File
@@ -0,0 +1,85 @@
# Connecting Housecarl to Jarl
Housecarl uses [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND
manager, for its DNS records. In this version that means one thing: the
DNS-01 challenge for the web UI's certificate ([certificates.md](certificates.md)).
Naming hosts in rules by their DNS names may use the same connection later.
Housecarl talks to Jarl's REST API with an API token. Give it a **user of its
own**, with **only the permission it needs**.
## 1. A user for Housecarl, in Jarl
On the Jarl server, make a user without a password. It can't log in to
Jarl's web UI, only use its tokens. Grant it the challenge record of
Housecarl's own name, and nothing else:
```sh
jarl user create housecarl --no-password --comment "Housecarl on fw01" \
--grant "records:write zones=adbraun.lol names=_acme-challenge.fw.adbraun.lol types=TXT"
```
Use your zone for `adbraun.lol`, and the name Housecarl's web UI is reached
by for `fw.adbraun.lol` (`tls.acme.domain`). The grant lets the token add
and remove TXT records at exactly `_acme-challenge.fw.adbraun.lol`:
* nothing else in the zone (`names=`);
* no other record type (`types=TXT`);
* no other zone (`zones=`).
It can see that the zone exists, which it needs to find it, but it can't
read the zone's other records.
Why not something wider:
* **Jarl's `acme` role** (`records:write names=_acme-challenge.* types=TXT`)
would let the token publish challenges for *any* name in *any* zone, and
so obtain certificates for all of them. Housecarl needs only its own name.
* **Your own account's token** would carry all your permissions, and
Housecarl's certificate would stop renewing the day your account changes.
A user per program keeps the two apart, and the audit log shows
`housecarl/...` for what Housecarl did.
If the challenge name is a CNAME into a zone kept for challenges, such as
`_acme-challenge.fw.adbraun.lol CNAME fw.acme.adbraun.lol.`, grant the
target instead: `zones=acme.adbraun.lol names=fw.acme.adbraun.lol`.
## 2. The token
Make the token, and copy it to the Housecarl server into a file only the
`housecarl` user can read:
```sh
jarl token create fw01 --user housecarl -q > housecarl.token # on the Jarl server, or with the jarl CLI anywhere
scp housecarl.token fw01:
ssh fw01 'sudo install -m 0600 -o housecarl -g housecarl housecarl.token /etc/housecarl/jarl.token && rm housecarl.token'
rm housecarl.token
```
A token file is better than putting the token in `housecarl.yaml`: the
configuration file is readable by the `housecarl` group, and gets copied
around more readily.
You can give the token an expiry (`--expires 365d`). Rotate it in Jarl with
`jarl token rotate ID --grace 1d`, then put the new secret in the file and
restart housecarld within the grace period. Since the certificate is renewed
every couple of months, nothing breaks while you do.
## 3. Housecarl's settings
In `/etc/housecarl/housecarl.yaml`:
```yaml
jarl:
server: https://dns.adbraun.lol:8053
token_file: /etc/housecarl/jarl.token
# ca_cert: /etc/housecarl/jarl-ca.pem # if Jarl's certificate isn't from a public CA
```
Then restart housecarld (`systemctl restart housecarl`). Housecarl needs to
reach Jarl's API over the network: Jarl's `listen` must include an
address Housecarl can reach, ideally over HTTPS.
`hc cert check` ([certificates.md](certificates.md#checking)) tests
the connection, the token's permission, and that the record reaches the
internet.
+82
View File
@@ -0,0 +1,82 @@
# NAT
## Port forwards
A port forward publishes an inside host's service: connections to a
public port are sent to the host, and let through the forward chain.
One object does what takes three rules by hand (the DNAT, the forward
accept, and the hairpin masquerade).
```sh
hc forward add 8096 --to 10.0.10.57:443 --public 203.0.113.7 --hairpin --name jellyfin
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 203.0.113.7 --hairpin --name terraria
hc forward add 25 --to 10.0.10.25 --from mail-relays --name smtp
hc forward list
```
| | |
|---|---|
| `PORTS` | the public port or range |
| `--proto` | `tcp`, `udp` or both (`tcp,udp`); `tcp` by default |
| `--to HOST[:PORT]` | the inside IPv4 host, and its port if it differs. A range keeps its ports |
| `--public` | the public IPv4 addresses (or a set) to match. Without it, any address arriving on an external zone |
| `--from` | only these sources may connect |
| `--hairpin` | the public address also works from inside (below); `--hairpin-from` limits that to some inside sources |
| `--to6` | an inside IPv6 host to open the port to (below) |
In the ruleset, a forward is a `dnat ip to` rule in the `dstnat` chain,
an accept in `forward` for connections it translated (`ct status dnat`),
placed after the built-ins and before your forward rules, so a later
"drop everything from wan" rule doesn't stop it.
### Hairpin NAT
From inside, a client that connects to the public address (because DNS
gives it) has its connection sent to the inside host, which would answer
it directly, and the client would drop the answer. With `--hairpin`,
Housecarl also masquerades those connections, so the answers come back
through the firewall. It needs `--public`: without the public address,
there is nothing to recognise those connections by.
### IPv6
IPv6 needs no translation: inside hosts have public addresses. `--to6`
opens the same port (or the `--to` port, if given) to an IPv6 host:
```sh
hc forward add 8096 --to 10.0.10.57:443 --to6 2001:db8:7f3d:10::57 --public 203.0.113.7 --name jellyfin
```
IPv6 clients connect to `[2001:db8:7f3d:10::57]:443` directly. A forward
can have only `--to6`, to open an IPv6 port alone.
## Source NAT
Source NAT rewrites the source of traffic leaving the firewall:
masquerade (the outgoing interface's address) or a fixed address with
`--snat-to`. Like rules, they are in order and match by zone, address and
service; the first that matches decides.
```sh
hc snat add --out wan --from trusted --comment "the LAN to the internet"
hc snat add --out wg0 --not-out --from wg-clients
hc snat add --out wan --from fd00:abab::/48 --family ipv6 --snat-to 2001:db8:7f3d::1
```
Source NAT is IPv4 only unless `--family ipv6` says otherwise: with a
delegated prefix, IPv6 needs no NAT, and NAT66 is for unusual cases
(a ULA network behind one public address).
## Redirects
A redirect sends matching traffic to a port on the firewall itself, for
example to make every client use the firewall's DNS server:
```sh
hc redirect add --in lan --service dns --not-to 10.0.10.5 --comment "force DNS"
hc redirect add --in lan --service http --to-port 3128
```
Without `--to-port`, the service's own port. The input chain must
accept the redirected traffic: redirected DNS needs a rule accepting DNS
from `lan`.
+139
View File
@@ -0,0 +1,139 @@
# Running Housecarl
## The services
```sh
systemctl status housecarl # housecarld: the API, the web UI, the log
journalctl -u housecarl -f
systemctl reload housecarl # reread the TLS certificate
systemctl status housecarl-ruleset # the ruleset loaded at boot
```
The firewall itself is the kernel's: it stays in force while housecarld
is stopped, restarted or upgraded. Without housecarld there is no API,
no web UI, no log and no probation timer.
housecarld logs each change it applies, rollbacks, and what it couldn't
apply. When it starts, it applies the configuration again (rolling back
first what was on probation when it stopped).
## Changes on probation
Changes made over the network roll back unless confirmed
([design.md](design.md#not-locking-yourself-out)):
```sh
hc status # Probation: 2 changes roll back in 97s unless confirmed
hc confirm
hc rollback # or undo them now
```
The web UI shows a banner with a countdown and both buttons. A rollback
is in the history, as an undo by `housecarl` via `rollback`, so it can be
undone like any change.
If you are cut off by a change: wait. When the time is up, the previous
ruleset comes back by itself. If you are cut off by a confirmed change,
use the console, or SSH from a source the rules still let in:
```sh
sudo hc history list
sudo hc history undo 42
```
## The ruleset at boot
A confirmed ruleset (or one changed through the socket) is saved to
`/var/lib/housecarl/ruleset.nft`, which `housecarl-ruleset.service`
loads before the network comes up. To see what would load:
```sh
sudo cat /var/lib/housecarl/ruleset.nft
sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
```
Dynamic sets start empty after a reboot.
Keep Debian's `nftables.service` disabled, or `/etc/nftables.conf`
without `flush ruleset`: otherwise it deletes Housecarl's table at
boot. `housecarl-ruleset.service` runs after it, which covers the
ordinary case.
## Hits
Every rule, NAT rule, built-in and policy has a counter. `hc rule list`,
`hc forward list` and the web UI show packets; `hc counters` shows them
all, with bytes. They count from housecarld's start, and carry across
changes to the ruleset.
## The log
Rules with `--log` (Log in the web UI) and, with `hc settings set
--log-drops on`, what the input and forward policies drop (at most 10
packets a second) are logged:
```sh
hc log -f
hc log --search 198.51.100.7
```
```
10:02:13 hc input #95 drop: wan - 198.51.100.7:53868 -> 203.0.113.7:22 tcp syn
10:02:15 hc forward policy drop: wan enp2s0 198.51.100.9:443 -> 10.0.10.20:51432 tcp
```
The packets go to netlink log group 4171, where housecarld reads them;
the last 2000 are kept in memory. They aren't written to the kernel log
or the journal. A rule's log isn't rate-limited: on a busy rule, give it
a limit, or log only new connections (`--state new`).
## When something doesn't get through
1. `hc log -f` with `--log-drops on`: is it dropped by a policy (no rule
accepted it), or by a rule (its number is in the line)?
2. `hc rule list forward` (or `input`): hits on the rule you expect?
3. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces
and the sets' families.
4. `hc status`: other tables. A Docker or Incus table, or an old one,
can drop what Housecarl accepts.
5. Port forwards: `hc forward list` hits, and the inside host's own
firewall and default route (answers must go back through Housecarl).
## Backups
`housecarl-backup.timer` runs `housecarld backup` daily. It writes a
consistent copy of the database to `backup.dir`
(`/var/backups/housecarl/housecarl-<time>.db`), and deletes those older
than `backup.retention` (30 days), keeping at least
`backup.keep_at_least` (7). A backup holds everything: configuration,
history, users, tokens (hashed) and the audit log.
```sh
sudo -u housecarl housecarld backup # now
sudo -u housecarl housecarld backup /tmp/h.db # to a file of your choice
sudo systemctl disable --now housecarl-backup.timer # no daily backups
```
To **restore**, stop housecarld first; restore refuses to replace the
database under it:
```sh
sudo systemctl stop housecarl
sudo -u housecarl housecarld restore /var/backups/housecarl/housecarl-20261014T020000Z.db
sudo systemctl start housecarl
```
housecarld applies the restored configuration when it starts. The old
database is kept next to it as `housecarl.db.before-restore-<time>`.
## Upgrades
`apt upgrade` restarts a running housecarld. A new version upgrades the
database when it starts, and an older one refuses a database a newer one
has upgraded: restore a backup to go back.
## Checks
`housecarld check` validates the configuration in the database against
this machine's interfaces, the same checks as `hc status` shows
([validation.md](validation.md)).
+86
View File
@@ -0,0 +1,86 @@
# Users, permissions and tokens
## Users
**Administrators** may do everything, including managing users. Everyone
else gets **grants**, `resource:access`:
| Resource | read | write |
|---|---|---|
| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation (the web UI shows the forms locked) | change them, undo, import, confirm and roll back |
| `sets` | dynamic sets' entries | add and remove entries |
| `log` | hit counters and the log | – |
| `audit` | the audit log | – |
| `*` | all of the above | |
A `sets` grant can name the sets it covers: `sets:write:guests` may
change the entries of the set `guests` and no other;
`sets:read:guests,blocked` may read two. That is the grant for another
program, such as Steward's captive portal, which should be able to let a
client in and nothing more. A dynamic set's entries need a `sets` grant
even for those with `config:write`.
Only administrators may delete other tables (`hc takeover`), and manage
users and the certificate (`hc cert`, Server → Certificate).
**Roles** are ready-made sets of grants:
| Role | Grants |
|---|---|
| `viewer` | `config:read sets:read log:read` |
| `operator` | `config:write sets:write log:read audit:read` |
| `log` | `log:read`, e.g. for a monitoring script |
```sh
hc user add sam --role operator
hc user add portal --no-password --grant sets:write:guests # tokens only
hc user update sam --grant config:read --grant log:read
```
The last administrator can't be removed, demoted or disabled.
## API tokens
A token has its user's permissions, or fewer. A token made with `--role`
or `--grant` can do only that, even if its user can do more:
```sh
hc token create portal --user portal --grant sets:write:guests --expires 365d
```
The secret is shown once; only its hash is stored. A restricted token can
only create tokens with a subset of its own grants. The web UI only offers
roles within what your account may do; a token with more would get no
more than its user anyway.
**Rotating** gives a token a new secret, keeping its ID, name and
permissions, so only the programs that use it need updating:
```sh
hc token rotate 7 --grace 1h # the old secret works for another hour
hc token rotate --current --save # this CLI's own token
```
A token that expires gets its lifetime again, from now (`--expires 90d`
or `never` to change it). Without `--grace` the old secret stops at once:
use that when it may have leaked. Using an old secret during its grace
period is logged, naming the token and the address, so you can find the
programs still to update. A restricted token may rotate itself and tokens
no wider than itself. `hc token list` shows when each token was last
used, and from where. `hc token revoke ID` stops one at once.
## The local socket
On the firewall, `/run/housecarl/housecarl.sock` lets root and the
`housecarl` user act as administrator without a token, and their changes
aren't on probation. The audit log shows them by their system user name.
Both can already change the machine's firewall, so this adds convenience,
not power. Anyone else on the socket needs a token, and their changes are
on probation like any other.
## The audit log
Logins (and failed ones), users, tokens, every configuration change (with
its history entry), confirmations and rollbacks, imports, takeovers and
dynamic set entries are recorded with who, from where and how: `session`,
`token` or `socket`.
+201
View File
@@ -0,0 +1,201 @@
# Zones, sets, services and rules
## Zones
A zone names one or more interfaces, so rules can say "from the LAN"
rather than `enp2s0`. An interface belongs to one zone at most. A trailing
`*` matches many: `incusbr*`.
```sh
hc zone add wan --interface wan --external
hc zone add lan --interface enp2s0 --interface 'enp2s0.*'
hc zone add wg --interface wg0
```
**External** zones face the internet. Antispoofing, MSS clamping, the
DHCPv6 client rule and port forwards (without public addresses) apply on
them. Everything else is internal: ping to the firewall from internal
zones is accepted by a built-in.
A zone may name an interface that doesn't exist yet (a WireGuard
interface that comes up later): its rules match once it appears, and
`hc status` warns until then.
## Address sets
A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
mixed:
```sh
hc set add trusted 10.0.10.0/24 10.20.30.0/24 2001:db8:7f3d:10::/64
hc set add dns-servers 10.0.10.5 2001:db8:7f3d:10::5 10.0.10.250-10.0.10.254
```
In the ruleset each becomes two nftables sets, `trusted_v4` and
`trusted_v6` (one if the set has only one family). A rule that names it
becomes one nftables rule per family. Rules can also give addresses
directly (`--from 10.0.10.3`); a set is better when several rules share
them.
A **dynamic** set's entries are added at run time, each with a timeout,
and nftables removes them when it runs out, even with housecarld stopped:
```sh
hc set add guests --dynamic --timeout 24h
hc set entries add guests 10.0.30.17 # for 24 hours
hc set entries add guests 2001:db8:7f3d:30::17 --timeout 2h
hc set entries guests # with the time left
hc set entries remove guests 10.0.30.17
```
Entries aren't configuration: they aren't in the history and aren't on
probation, but each change is in the audit log. They survive every
change to the ruleset, and a restart of housecarld; a reboot empties
them. A token can be limited to one set ([permissions.md](permissions.md)):
that is how another program (Steward's captive portal) lets a client in.
## Services
A service names ports of a protocol (`tcp`, `udp`, `sctp`) and ICMP
types:
```sh
hc service add jellyfin tcp/8096 tcp/8920
hc service add games udp/27015-27030 tcp/27015
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big
```
Many are built in (`hc service list`): `ssh`, `dns` (TCP and UDP 53),
`dot`, `http`, `https` (TCP, and UDP for QUIC), `smtp`, `submission`,
`smtps`, `imaps`, `ntp`, `dhcp`, `dhcpv6`, `wireguard`, `openvpn`, `ipsec`,
`snmp`, `syslog`, `mdns`, `rdp`, and `ping` (ICMP and ICMPv6 echo
requests). Adding a service with a built-in's name replaces it. Rules may
also give an entry directly: `--service tcp/8080`.
## Rules
Rules are kept in order, in four chains:
| Chain | | Hook |
|---|---|---|
| `input` | traffic to the firewall itself | input |
| `forward` | traffic through it, between zones | forward |
| `output` | traffic from it | output |
| `early` | before connection tracking: cheap drops of what can never be right | prerouting, priority raw |
Each chain is checked top to bottom, and the first rule that accepts,
drops or rejects decides. What no rule decides gets the chain's
**policy** (Settings): by default input and forward drop, output
accepts.
```sh
hc rule add input --from trusted --service ssh --comment "SSH from home"
hc rule add input --in wg --service dns
hc rule add forward --in lan --out wan
hc rule add forward --in wan -a drop --comment "nothing else gets in"
hc rule list input
hc rule move 12 --before 7
```
A rule matches on what it gives, and all of it:
| | |
|---|---|
| `--in`, `--out` | zones it comes in on, goes out on (`--not-in`, `--not-out`: any other). Input has no out zone, output no in zone |
| `--from`, `--to` | sets, addresses, prefixes, ranges (`--not-from`, `--not-to`: anything else) |
| `--service` | services or entries (`tcp/8080`, `icmp/echo-request`) |
| `--family` | `ipv4` or `ipv6` only; otherwise it follows the addresses |
| `--state` | connection states: `new`, `established`, `related`, `invalid`, `untracked` |
| `--status` | connection status: `dnat` (port forwarded), `snat` |
| `--days`, `--hours` | a time window: `--days Monday,Friday --hours 08:00-17:30`; `22:00-06:00` passes midnight. In the firewall's time zone, as the kernel knows it |
| `--limit` | a rate: `10/minute`, with `,burst=N`, `,over` (match beyond the rate, to drop a flood) and `,per-source` (a rate for each source address) |
| `--conn-limit N` | a source with more than N connections |
The action is `accept`, `drop`, `reject` (an ICMP error, or with
`--reject-with tcp-reset` a TCP reset) or `log` (log and go on).
`--log` logs the packets of any rule ([operations.md](operations.md#the-log)).
`--disabled` (or `hc rule disable ID`) keeps a rule without using it.
Examples:
```sh
# SSH from the internet, at most 3 new connections a minute per source
hc rule add input --in wan --service ssh --state new --limit 3/minute,burst=5,over,per-source -a drop --log
hc rule add input --in wan --service ssh
# Children's devices offline at night
hc rule add forward --from kids --out wan --hours 22:00-07:00 -a reject
# What was port forwarded may go on, the rest from the internet may not
hc rule add forward --in wan --status dnat
hc rule add forward --in wan -a drop
```
### Raw rules
For what Housecarl can't say, a rule can be one nftables rule written by
hand, placed like any other:
```sh
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'
```
It is checked by nftables with the rest of the ruleset, and can use the
sets' nftables names (`@trusted_v4`). The importer makes raw rules from
what it can't translate. It goes into the ruleset as written: add
`counter` to it to count its packets.
## Built-ins
Housecarl adds the rules every firewall needs before yours. Each can be
turned off (`hc settings set --builtin NAME=off`; Settings in the web
UI), and `hc ruleset` shows them:
| Built-in | Default | Chains | What |
|---|---|---|---|
| `loopback` | on | input (and output if its policy is drop) | accept everything on `lo` |
| `established` | on | input, forward (and output) | accept established and related connections |
| `drop_invalid` | on | input, forward | drop packets connection tracking calls invalid |
| `icmpv6` | on | input, forward (and output) | the ICMPv6 IPv6 can't work without (RFC 4890): errors and echo; neighbour discovery with hop limit 255 and MLD from link-local, to the firewall |
| `ping_internal` | on | input | ping (ICMP echo) to the firewall from internal zones |
| `dhcpv6_client` | on | input | DHCPv6 replies from link-local on external zones, so the firewall gets its prefix delegation |
| `antispoof` | on | early | drop sources that can't come from the internet, on external zones: the antispoof lists (private, loopback, documentation and multicast ranges) and your own prefixes |
| `mss_clamp` | off | forward | clamp TCP's MSS to the route's MTU on external zones: needed with PPPoE |
**Your prefixes** (`--own-prefix 2001:db8:7f3d::/48`) are your delegated
IPv6 prefix and public addresses: nothing from outside should come from
them. The antispoof lists can be changed with `--antispoof-v4` and
`--antispoof-v6`.
The **flowtable** (`--flowtable wan --flowtable enp2s0`) lets established
TCP and UDP connections forwarded between those devices skip the rules,
which helps on a slow CPU. It needs exact device names. Counters of
forward rules then only count each connection's first packets.
## The ruleset
`hc ruleset` prints what Housecarl builds; the web UI and `hc rule list`
show each rule's hits. The table looks like this (shortened):
```
table inet housecarl {
set trusted_v4 { type ipv4_addr; flags interval; elements = { 10.0.10.0/24, 10.20.30.0/24 } }
set trusted_v6 { type ipv6_addr; flags interval; elements = { 2001:db8:7f3d:10::/64 } }
chain early { type filter hook prerouting priority raw; policy accept; ... }
chain input { type filter hook input priority filter; policy drop;
iifname "lo" accept comment "hc:b-loopback"
ct state established,related counter accept comment "hc:b-established"
...
ip saddr @trusted_v4 tcp dport 22 counter accept comment "hc:r4 SSH from home"
ip6 saddr @trusted_v6 tcp dport 22 counter accept comment "hc:r4 SSH from home"
counter comment "hc:p-input"
}
chain forward { ... }
chain output { ... }
chain dstnat { type nat hook prerouting priority dstnat; ... }
chain srcnat { type nat hook postrouting priority srcnat; ... }
}
```
Other tables still apply: a packet must get past every table's chains on
its hook. The dashboard and `hc status` list them.
+108
View File
@@ -0,0 +1,108 @@
# Validation
Every change is checked as a whole configuration before it is applied.
An **error** stops the change; a **warning** is reported with it, and
stays on the dashboard and in `hc status` until fixed. Each problem has a
stable code, shown by the CLI as `[code]` and in the API as
`problems[].code`. `housecarld check` runs the checks on the saved
configuration.
After Housecarl's own checks, **nftables** checks the rendered ruleset
(`nft -c`). What it refuses (mostly raw rules) comes back as a
`firewall` error with nftables' message, and nothing is changed.
The warnings marked *host* compare the configuration with this machine's
interfaces.
## Names
Zones, sets, services and port forwards have names (a forward's is
optional): a letter, then letters, digits, `-` and `_`, up to 48. Names
are unique per kind, ignoring case.
| Code | | When |
|---|---|---|
| `zone_name`, `set_name`, `service_name`, `forward_name` | error | not a valid name, or one that looks like an address |
| `zone_name_taken`, `set_name_taken`, `service_name_taken`, `forward_name_taken` | error | another object of the kind has that name |
## Settings
| Code | | When |
|---|---|---|
| `policy` | error | a policy that isn't `accept` or `drop` |
| `settings_address` | error | an own prefix or antispoof entry that isn't an address or prefix, or is in the wrong list (an IPv6 prefix in the IPv4 list) |
| `flowtable_device` | error | a flowtable device with a wildcard: name each device |
| `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does |
| `probation_range` | error | the probation isn't 10 to 3600 seconds |
## Zones
| Code | | When |
|---|---|---|
| `zone_empty` | error | no interfaces |
| `zone_interface` | error | not an interface name (up to 15 characters; a trailing `*` matches many) |
| `zone_interface_taken` | error | the interface is in another zone already |
| `zone_interface_missing` | warning, *host* | the interface doesn't exist here (yet); its rules match once it does |
| `no_external_zone` | warning | no zone is external: antispoofing, MSS clamping, the DHCPv6 client rule and port forwards without public addresses need one |
## Sets and services
| Code | | When |
|---|---|---|
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
| `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second |
| `service_empty` | error | a service with no entries |
| `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows |
## Matches
These apply to rules, source NAT and redirects.
| Code | | When |
|---|---|---|
| `unknown_zone` | error | a zone that doesn't exist |
| `match_in_zone` | error | in zones on the output chain (it has no incoming interface) |
| `match_out_zone` | error | out zones on the input or early chain, or on a redirect |
| `match_address` | error | an address that can't be read |
| `unknown_set` | error | a name that is neither an address nor a set |
| `unknown_service` | error | a service that is neither built in nor yours |
| `family` | error | a family that isn't `ipv4` or `ipv6` |
| `match_never` | error | the addresses, services and family can't all match the same packet (IPv4 sources and an `icmpv6` service) |
## Rules
| Code | | When |
|---|---|---|
| `rule_chain` | error | a chain that isn't `input`, `forward`, `output` or `early` |
| `rule_action` | error | an action that isn't `accept`, `drop`, `reject` or `log` |
| `raw_rule` | error | a raw rule over several lines, or with `;` |
| `early_conntrack` | error | connection states, limits or a connection limit in the early chain, which runs before connection tracking |
| `early_reject` | error | reject in the early chain: drop instead |
| `ct_state`, `ct_status` | error | an unknown connection state or status |
| `rule_day`, `rule_hours` | error | an unknown day, or hours that aren't `HH:MM`, or only one of them |
| `rule_limit` | error | a rate that isn't a number per second, minute, hour or day |
| `rule_conn_limit` | error | a connection limit under 1 |
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
| `log_prefix` | error | a log prefix over 100 characters |
## Port forwards
| Code | | When |
|---|---|---|
| `forward_protocol` | error | no protocols, or one that isn't `tcp`, `udp` or `sctp` |
| `forward_ports` | error | the public port isn't a port or range |
| `forward_to_port` | error | the inside port isn't one port, or is given for a range of public ports |
| `forward_target` | error | neither `to` nor `to6`, or `to` that isn't one IPv4 address, or `to6` one IPv6 address |
| `forward_public` | error | no public addresses and no external zone, or an IPv6 public address |
| `forward_overlap` | error | another forward has the same protocol, port and public address |
| `forward_hairpin` | warning | hairpin without public addresses: inside clients' connections can't be recognised |
## Source NAT and redirects
| Code | | When |
|---|---|---|
| `snat_to` | error | `to` isn't one address, or of another family than the rule (give the rule the family) |
| `snat_everything` | warning | no out zones and no sources: everything leaving the firewall is translated |
| `redirect_service` | error | no service, or one without TCP, UDP or SCTP ports |
| `redirect_to_port` | error | the local port isn't one port |