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:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
No files matched your search
+181
@@ -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=]` | |
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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)).
|
||||
@@ -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
@@ -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.
|
||||
@@ -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 |
|
||||
Reference in new issue
Block a user