Compare commits

...
2 Commits
Author SHA1 Message Date
abrown38andClaude Opus 5.5 c17f5cd9f0 Release 0.1.0
release / release (push) Successful in 1m2s
Housecarl 0.1.0. Since rc.7: the boot ruleset fails closed (a fallback
keeps anything new from the internet out if it doesn't load), the
flowtable follows devices that appear later or reconnect, /healthz for
monitoring, renaming zones, sets and services with their references,
and an import that keeps a chain's head in order. make test (with nft
1.1.3) and make e2e (242 checks) pass.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:16:46 +01:00
39 changed files with 1340 additions and 87 deletions

No files matched your search

+34 -1
View File
@@ -6,6 +6,8 @@ All notable changes to Housecarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
## [0.1.0] - 2026-10-19
The first version of Housecarl, a firewall manager for nftables, built The first version of Housecarl, a firewall manager for nftables, built
the way Jarl and Steward are. the way Jarl and Steward are.
@@ -66,7 +68,38 @@ the way Jarl and Steward are.
open. open.
- **At boot:** the confirmed ruleset is loaded by - **At boot:** the confirmed ruleset is loaded by
`housecarl-ruleset.service` before the network comes up, without `housecarl-ruleset.service` before the network comes up, without
housecarld. An empty configuration applies nothing. housecarld. An empty configuration applies nothing. It fails closed:
if the saved ruleset doesn't load, a fallback saved beside it
(`ruleset-fallback.nft`) is, until housecarld puts the real one back:
nothing new gets in from external zones, the inside keeps working
(masqueraded), and the unit is marked failed with a critical message.
`hc status`, the dashboard and `/healthz` say while the fallback is in
force; the warning `no_external_zone` says when no zone is external,
which would make the fallback close everything (docs/operations.md).
- **The flowtable and interfaces that come and go:** the boot ruleset
has no flowtable (nftables on Debian 13's kernel refuses one naming a
device that doesn't exist yet, such as `ppp0` before PPPoE is up, and
with it the whole file); housecarld watches the kernel's link messages
and applies the ruleset again when a flowtable device appears or comes
back as a new interface (PPPoE reconnecting). A guide to when the
flowtable helps, and what it costs (docs/flowtable.md).
- **`GET /healthz` for monitoring:** `ok`, `firewall` (applied,
unconfigured, fallback, missing), changes on probation and the
machine's warnings, with HTTP 503 unless the firewall is in force;
no login needed, read at most every five seconds.
- **Import keeps a chain's head in order:** once a would-be built-in at
the head of a chain has to stay a rule (an invalid drop the forward
chain needs after an exception), the built-ins after it stay rules
too, in their place, instead of moving ahead of it: an ICMPv6 accept
no longer jumps ahead of the invalid drop it came after.
- **Renaming a zone, set or service** changes the rules, NAT rules and
port forwards that name it, in the same change (one history entry, one
undo); before, a name in use couldn't change. Dynamic sets keep their
names (their tokens and programs go by them).
- VLAN sub-interfaces (`enp2s0.20`) in zones and the flowtable, tested
with tagged traffic (docs/rules.md).
- `hc settings set --flowtable none` (and `none` for the other lists)
empties the list.
- **Warnings about the machine:** while `nftables.service` is enabled - **Warnings about the machine:** while `nftables.service` is enabled
(it loads `/etc/nftables.conf` at boot, and flushes every table when (it loads `/etc/nftables.conf` at boot, and flushes every table when
stopped), and while the table an import came from still applies. stopped), and while the table an import came from still applies.
+8 -5
View File
@@ -37,7 +37,8 @@ table of its own.
* **The rules every firewall needs**, built in and each one switchable: * **The rules every firewall needs**, built in and each one switchable:
loopback, established connections, invalid packets, the ICMPv6 that loopback, established connections, invalid packets, the ICMPv6 that
IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the
DHCPv6 client, and a flowtable fast path. DHCPv6 client, and a flowtable fast path
([docs/flowtable.md](docs/flowtable.md)).
* **Changes you can trust.** Each change is validated as a whole, checked * **Changes you can trust.** Each change is validated as a whole, checked
by nftables itself, and applied in one transaction. As in Jarl, edits by nftables itself, and applied in one transaction. As in Jarl, edits
can be collected in a changeset, reviewed with the diff of the ruleset, can be collected in a changeset, reviewed with the diff of the ruleset,
@@ -56,7 +57,8 @@ table of its own.
the history of every change, field by field, with undo. the history of every change, field by field, with undo.
* **Its own table only.** Docker, Incus and libvirt keep theirs. The * **Its own table only.** Docker, Incus and libvirt keep theirs. The
confirmed ruleset is loaded at boot, before the network comes up, even confirmed ruleset is loaded at boot, before the network comes up, even
if housecarld doesn't start. if housecarld doesn't start; should it fail to load, a fallback keeps
anything new from the internet out until housecarld puts it back.
* **Sets other programs can fill.** A dynamic set's entries expire by * **Sets other programs can fill.** A dynamic set's entries expire by
themselves, and a token can be limited to one set: that is how themselves, and a token can be limited to one set: that is how
Steward's captive portal will let a guest in. Steward's captive portal will let a guest in.
@@ -102,7 +104,8 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
* [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins * [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins
* [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects * [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects
* [import.md](docs/import.md): moving from an existing nftables ruleset * [import.md](docs/import.md): moving from an existing nftables ruleset
* [operations.md](docs/operations.md): probation, the boot, hits and the log, backups, troubleshooting * [operations.md](docs/operations.md): probation, the boot and its fallback, hits and the log, monitoring, backups, troubleshooting
* [flowtable.md](docs/flowtable.md): the fast path for forwarded traffic, and when (not) to use it
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl * [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
* [jarl.md](docs/jarl.md): connecting Housecarl to Jarl, and what its token may do * [jarl.md](docs/jarl.md): connecting Housecarl to Jarl, and what its token may do
* [cli.md](docs/cli.md): the `hc` command line client * [cli.md](docs/cli.md): the `hc` command line client
@@ -115,11 +118,11 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.1.0 is in testing (0.1.0-rc.7): zones, address sets and Housecarl 0.1.0 is released: zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset network changes (confirmed by the client that made them), the ruleset
at boot, hits and the drop log, nftables at boot with a fail-closed fallback, hits and the drop log, nftables
import, changesets reviewed with the ruleset's diff (as in Jarl), import, changesets reviewed with the ruleset's diff (as in Jarl),
bandwidth limits and quotas, a web UI, REST API and CLI. bandwidth limits and quotas, a web UI, REST API and CLI.
+1 -1
View File
@@ -1 +1 @@
0.1.0-rc.7 0.1.0
+4
View File
@@ -176,6 +176,10 @@ dhcpv6_client, antispoof, mss_clamp.
} }
for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} { for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} {
if fl.Changed(flag) { if fl.Changed(flag) {
// "none" (or "") empties the list.
if len(v) == 1 && (v[0] == "none" || v[0] == "") {
v = []string{}
}
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
} }
} }
+19
View File
@@ -30,6 +30,7 @@ import (
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/hostif" "housecarl/internal/hostif"
"housecarl/internal/jarl" "housecarl/internal/jarl"
"housecarl/internal/linkwatch"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/nflog" "housecarl/internal/nflog"
"housecarl/internal/nft" "housecarl/internal/nft"
@@ -196,6 +197,24 @@ func serveCmd() *cobra.Command {
a.log.Error("saving the ruleset for the next boot failed", "err", err) a.log.Error("saving the ruleset for the next boot failed", "err", err)
} }
} }
// A flowtable device that appears later (ppp0 once PPPoE is up,
// or again after it reconnects) is added by applying again.
go func() {
err := linkwatch.Watch(ctx, func() {
host.Forget()
if !fw.FlowtableStale(eng.State()) {
return
}
if err := eng.ApplyCurrent(ctx); err != nil {
a.log.Error("applying the ruleset again for the flowtable's devices failed", "err", err)
return
}
a.log.Info("interfaces changed: applied the ruleset again, with the flowtable's devices that exist now")
})
if err != nil {
a.log.Warn("not watching the interfaces: a flowtable device that appears later is only added by the next change", "err", err)
}
}()
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket) go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
go srv.RunQuotas(ctx) go srv.RunQuotas(ctx)
srv.SetConntrack(conntrack.Client{}) srv.SetConntrack(conntrack.Client{})
+6 -2
View File
@@ -1,5 +1,8 @@
# Housecarl: loads the last confirmed ruleset at boot, before the network # Housecarl: loads the last confirmed ruleset at boot, before the network
# comes up and whether or not housecarld starts. # comes up and whether or not housecarld starts. If it fails to load, the
# fallback housecarld saved beside it is loaded instead: nothing new gets
# in from external zones (docs/operations.md), and the unit is marked
# failed, until housecarld starts and puts the real ruleset back.
# Installed as /etc/systemd/system/housecarl-ruleset.service. # Installed as /etc/systemd/system/housecarl-ruleset.service.
[Unit] [Unit]
Description=Housecarl firewall ruleset Description=Housecarl firewall ruleset
@@ -15,7 +18,8 @@ ConditionPathExists=/var/lib/housecarl/ruleset.nft
[Service] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /var/lib/housecarl/ruleset.nft # "<2>": logged as critical.
ExecStart=/bin/sh -c '/usr/sbin/nft -f /var/lib/housecarl/ruleset.nft && exit 0; echo "<2>the saved ruleset failed to load: loading the fallback (nothing new gets in from external zones) until housecarld puts the real one back" >&2; /usr/sbin/nft -f /var/lib/housecarl/ruleset-fallback.nft; exit 1'
[Install] [Install]
WantedBy=sysinit.target WantedBy=sysinit.target
+1 -1
View File
@@ -74,7 +74,7 @@ Creating answers 201, other changes 200.
| | | | | |
|---|---| |---|---|
| `GET /healthz` | `{"ok": true, "version"}`, without authentication | | `GET /healthz` | `ok`, `version`, `firewall` (`applied`, `unconfigured`, `fallback`, `missing`), `probation` (changes waiting to be confirmed), `warnings` (codes); HTTP 503 unless `ok`. Without authentication; read at most every five seconds ([operations.md](operations.md#monitoring)) |
| `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 /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 /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
| `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `verdict` (allowed, blocked), `decided_by`, `rule_id`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`) ([rules.md](rules.md#the-zone-matrix)) | | `GET /matrix[?changeset=ID]` | the zone matrix: `zones`, `cells` (`from`, `to` (a zone, or `firewall`), `chain`, `verdict` (allowed, blocked), `decided_by`, `rule_id`, `exceptions`: `rule_id`, `chain`, `label`, `verdict` (allowed, blocked, logged, unknown), `what`) ([rules.md](rules.md#the-zone-matrix)) |
+2 -2
View File
@@ -148,7 +148,7 @@ by name.
|---|---| |---|---|
| `zone list`, `zone show ZONE` | | | `zone list`, `zone show ZONE` | |
| `zone add NAME -i IFACE... [--external] [--comment C]` | | | `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 update ZONE [-i IFACE]... [--add IFACE] [--remove IFACE] [--external[=false]] [--name N] [--comment C]` | `-i` replaces the interfaces; `--name` renames it, and the rules naming it follow |
| `zone delete ZONE` | refused while rules use it | | `zone delete ZONE` | refused while rules use it |
| `set list`, `set show SET` | | | `set list`, `set show SET` | |
| `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | | | `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | |
@@ -208,7 +208,7 @@ See [nat.md](nat.md).
| Command | | | Command | |
|---|---| |---|---|
| `settings show` | | | `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] [--auto-confirm on\|off] [--stats-per-device on\|off]` | lists given replace the old ones | | `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] [--auto-confirm on\|off] [--stats-per-device on\|off]` | lists given replace the old ones; `none` empties one (`--flowtable none`: off) |
### probation, history, import ### probation, history, import
+1 -1
View File
@@ -14,7 +14,7 @@ reads the file when it starts.
| `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.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)) | | `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)) | | `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 | | `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot; its fallback is saved beside it as `ruleset-fallback.nft` ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot)). Change both paths in the unit too |
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed | | `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 | | `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 | | `database` | `/var/lib/housecarl/housecarl.db` | the SQLite database: configuration, history, users, audit log |
+13
View File
@@ -139,6 +139,19 @@ firewall is up even if housecarld can't start. An unconfirmed ruleset is
never saved there: a reboot during probation brings back the last never saved there: a reboot during probation brings back the last
confirmed one. confirmed one.
It fails closed. A fallback saved beside it (`ruleset-fallback.nft`,
`render.Fallback`) is loaded if it doesn't: no sets, no flowtable,
interfaces by name only, so there is little in it to fail. It keeps
anything new from the external zones out and lets the inside work,
which is as far as a ruleset can go without knowing the rules; a
fallback that let everything in would leave a router open on the
internet, and one that let nothing in would lock out the person who has
to fix it. The boot ruleset has no flowtable either: nftables refuses a
flowtable naming a device that doesn't exist yet (on Debian 13's
kernel), and before the network is up, `ppp0` doesn't. housecarld
watches the kernel's link messages (internal/linkwatch) and applies
again when a flowtable device appears, or comes back as a new interface.
## The drop log ## The drop log
Rules with logging on, and the policies (Settings), send packets to Rules with logging on, and the policies (Settings), send packets to
+181
View File
@@ -0,0 +1,181 @@
# The flowtable: a fast path for forwarded traffic
The flowtable is a shortcut inside the kernel for traffic the firewall
**forwards** (between the internet and your LAN, between zones). It
speeds up routing on a machine whose CPU can't keep up with its line.
It doesn't make the firewall any safer or less safe, but it changes what
some features can see, so read [when not to use it](#when-not-to-use-it)
before you turn it on.
It is off until you name its devices.
## What it does
Without a flowtable, every forwarded packet takes the whole way through
the kernel: the prerouting hooks, the routing decision, Housecarl's
forward chain (the built-ins, then your rules) and postrouting with NAT.
For an established connection almost all of that work only ends in the
`established` built-in accepting it again, packet after packet.
With a flowtable, Housecarl adds one built-in to the forward chain:
```
meta l4proto { tcp, udp } flow add @ft
```
A TCP or UDP connection takes the whole way for its first packets, as
always: your rules decide whether it is allowed at all. Once the kernel
has seen it established (and your rules let it through), it is added
to the flowtable. From then on its packets are caught as soon as they
arrive on one of the flowtable's devices (the *ingress* hook) and sent
straight out on the other side: NAT is still applied, the TTL still
counts down, but every chain in between is skipped.
```
first packets every packet after
in ─► prerouting ─► routing ─► forward ─► postrouting ─► out
│ ▲
└────────────── flowtable (ingress) ─────────────────────┘
```
It is the software flowtable, run by the CPU. Offloading to the network
card's hardware isn't supported ([design.md](design.md#not-in-01)).
## When to use it
Turn it on when **the router's CPU is what limits your speed**:
* a speed test through the router gets less than your line gives (or
less than the same test from the router itself), and
* during it, one CPU core is busy with network work: in `top`, press `1`
for every core, and look at `si` (softirq) near 100 % on a core.
Typical cases: a small or old machine (Atom, Celeron, a Raspberry Pi, an
old Core 2) on a fast line (500 Mbit/s and up), lots of small packets
(gaming, VoIP, many downloads at once), or a machine that is busy with
other work besides routing. The gain is CPU time per packet: often well
over half of the forwarding cost, which shows up as speed only when the
CPU was the limit, and otherwise as a cooler, quieter, more responsive
machine.
## When not to use it
**When the CPU keeps up anyway.** If the speed test already reaches your
line and `si` stays low, the flowtable buys you nothing you'd notice,
and costs you the figures below. Most modern x86 machines route a
gigabit line without it.
**When you count or limit traffic in the forward chain.** The fast path
skips the forward chain, so whatever must see *every* packet of a
connection only sees the first few. Housecarl warns about each:
| Feature | With the flowtable | Warning |
|---|---|---|
| quotas and byte-rate limits on forward rules | only count first packets: a device can go far past its allowance | `flowtable_traffic` |
| forward rules with "every packet" (`--every-packet`) | only see first packets | `flowtable_traffic` |
| statistics' traffic per device (`stats_per_device`) | only count first packets | `stats_flowtable` |
| hit counters of the `established` built-in (forward) | stop rising once a connection is offloaded | |
Rate limits by packets (`--limit 10/second`) and connection limits act
on new connections, so they work the same.
**When other tables must see forwarded traffic.** Offloaded packets skip
the forward hooks of *every* table, not only Housecarl's: Docker's,
Incus', libvirt's or a table of your own. Those normally only judge new
connections, which still pass through them, but a table that counts or
shapes established traffic in its forward chain won't see it.
What keeps working as before:
* **Your rules** decide every new connection: nothing gets offloaded
that your rules didn't let through. A rule you add later doesn't stop
a connection already open, but that is true without the flowtable too
(the `established` built-in accepts it).
* **NAT**: port forwards, hairpin NAT, masquerading and SNAT.
* **The input and output chains**: traffic to and from the firewall
itself never uses the flowtable.
* **Closing connections** (`hc connections close`, Traffic →
Connections) ends their fast path too: the kernel drops a flow with
its tracking entry.
* **Interface statistics** (traffic per zone and interface) come from the
interfaces' own counters, which count every packet.
* **Traffic shaping with `tc`** on the outgoing interface still applies.
* **The log** and **the packet tracer** see what goes through the rules:
new connections, as always. The tracer doesn't see the packets of an
offloaded connection; trace a new one.
* ICMP, and protocols other than TCP and UDP (GRE, ESP, SCTP), always
take the normal way.
## How to turn it on
Name the devices whose traffic should take the fast path, **both
sides**: the interface connections come in on and the one their replies
come in on. For a router that means the external interface and the
inside ones:
```sh
hc settings set --flowtable wan --flowtable enp2s0
hc settings # Flowtable: wan, enp2s0
```
In the web UI: Settings → Flowtable, add each device. The fast path
only catches packets arriving on a listed device, so a connection to or
from an unlisted one gains little or nothing: leave a device out only on
purpose (say, a WireGuard interface whose traffic you'd rather keep on
the normal way).
* Use **exact device names**, not wildcards (`incusbr*` is refused):
the kernel attaches the flowtable to each device by name.
* With **PPPoE**, name the PPP interface (`ppp0`, or the name your
`ppp@` unit gives it), the one in your external zone.
* `--flowtable` replaces the whole list; `hc settings set --flowtable
none` turns the flowtable off.
The change is applied (and on probation, if made over the network) like
any other.
### Devices that come and go
PPP and WireGuard interfaces don't exist until their service brings
them up, and PPPoE makes a new `ppp0` each time it reconnects. Housecarl
handles that:
* A device that doesn't exist yet is left out of the flowtable, with the
warning `flowtable_device_missing`, instead of failing the whole
ruleset.
* housecarld watches the kernel's interface changes: when a flowtable
device appears, or comes back as a new interface after a reconnect,
it applies the ruleset again with it, a couple of seconds later. The
journal says `interfaces changed: applied the ruleset again`.
Applying again keeps the counters, dynamic sets and quotas, as any
change does, and adds no history entry.
* The ruleset saved for boot has no flowtable at all: it loads before
the network is up, when `ppp0` and `wg0` can't exist yet, and
nftables on Debian 13's kernel refuses a flowtable naming a device
that doesn't exist (and with it the whole file). housecarld adds the
flowtable once it starts. Until then, traffic takes the normal way,
through the same rules.
## Checking it works
```sh
sudo nft list flowtables # the devices it is attached to
sudo conntrack -L | grep OFFLOAD # connections on the fast path
```
`conntrack` is in Debian's `conntrack` package. A long download through
the router should show up as `[OFFLOAD]`. Compare a speed test, and `si`
in `top`, before and after.
If nothing gets offloaded, check that both of the connection's devices
are listed, that `flowtable_device_missing` isn't in `hc status`, and
that the connection is TCP or UDP and established.
## Turning it off
```sh
hc settings set --flowtable none
```
Connections on the fast path go back to the normal way with their next
packet.
+5 -2
View File
@@ -50,8 +50,11 @@ Import a ruleset.
established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client
rule, antispoofing) becomes that built-in only when it sits where the 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 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 a rule where it was, so the ruleset does what it did. Since built-ins
ruleset doesn't have are turned off. come before every rule, once one at a chain's head stays a rule, the
ones after it there stay rules too, in order: an invalid drop that
stays a rule keeps the ICMPv6 accepts after it behind it, as they were.
Built-ins your ruleset doesn't have are turned off.
* **DNAT** rules become port forwards, TCP and UDP of the same port * **DNAT** rules become port forwards, TCP and UDP of the same port
merged into one. The forward-chain accepts that let them through merged into one. The forward-chain accepts that let them through
become part of the port forward, and a masquerade for inside sources become part of the port forward, and a masquerade for inside sources
+75 -2
View File
@@ -71,14 +71,63 @@ sudo hc history undo 42
A confirmed ruleset (or one changed through the socket) is saved to A confirmed ruleset (or one changed through the socket) is saved to
`/var/lib/housecarl/ruleset.nft`, which `housecarl-ruleset.service` `/var/lib/housecarl/ruleset.nft`, which `housecarl-ruleset.service`
loads before the network comes up. To see what would load: loads before the network comes up, so before PPPoE, WireGuard or DHCP
bring up their interfaces. To see what would load:
```sh ```sh
sudo cat /var/lib/housecarl/ruleset.nft sudo cat /var/lib/housecarl/ruleset.nft
sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
``` ```
Dynamic sets start empty after a reboot. Dynamic sets start empty after a reboot. The saved ruleset has no
flowtable, since its devices may not exist yet: housecarld adds it when
it starts, and whenever a flowtable device appears
([flowtable.md](flowtable.md#devices-that-come-and-go)).
### If the ruleset fails to load at boot
Should `ruleset.nft` fail to load (a damaged file, an nftables or kernel
upgrade that refuses something in it), the machine would otherwise boot
with no firewall at all until housecarld starts. Instead,
`housecarl-ruleset.service` loads a **fallback** that housecarld saves
beside it, `/var/lib/housecarl/ruleset-fallback.nft`, from the same
confirmed configuration:
* **Nothing new gets in from the external zones' interfaces**, to the
firewall or through it: no port forwards, no SSH or web UI from the
internet.
* What the firewall and the inside need to keep working is let in:
established connections, the ICMPv6 IPv6 needs (neighbour discovery,
router advertisements, packet too big), and DHCPv6 replies from
link-local addresses.
* **Everything else is let through**: the LAN, WireGuard and the
firewall itself are reachable from the inside, so you can log in and
fix it.
* IPv4 leaving an external zone is masqueraded (when any source NAT is
configured), so the inside still reaches the internet. MSS clamping
stays, if it is on.
PPPoE itself isn't affected: its discovery and session are Ethernet
frames, and the PPP link's negotiation happens before any IP, neither
of which an `inet` table sees.
The fallback only knows which interfaces face the internet from the
zones marked **external**. With none, it lets nothing but loopback
through, and the warning `no_external_zone` says so.
When the fallback loads, the unit is marked failed, and the journal says
why (as a critical message):
```sh
systemctl status housecarl-ruleset
journalctl -b -u housecarl-ruleset
```
housecarld puts the real ruleset back when it starts, and saves a fresh
`ruleset.nft`. If it can't apply it either, the fallback stays in force:
`hc status` and the dashboard say "NOT in force: the boot fallback is in
force", and `/healthz` answers 503 ([Monitoring](#monitoring)). Fix
what `hc status` or the journal reports, then restart housecarld.
Keep Debian's `nftables.service` disabled (`systemctl disable Keep Debian's `nftables.service` disabled (`systemctl disable
nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and
@@ -224,6 +273,30 @@ database is kept next to it as `housecarl.db.before-restore-<time>`.
database when it starts, and an older one refuses a database a newer one database when it starts, and an older one refuses a database a newer one
has upgraded: restore a backup to go back. has upgraded: restore a backup to go back.
## Monitoring
`GET /healthz` needs no login, and answers within a few seconds of any
change (it reads the ruleset at most every five seconds, however often
it is asked):
```sh
$ curl -s https://firewall.example.net:8070/healthz
{"ok":true,"version":"0.1.0","firewall":"applied","probation":0,"warnings":[]}
```
| Field | |
|---|---|
| `ok` | `true` with HTTP 200 when the firewall is as it should be; `false` with HTTP **503** otherwise |
| `firewall` | `applied` (Housecarl's ruleset is in force), `unconfigured` (nothing configured yet, so nothing to apply), `fallback` (the [boot fallback](#if-the-ruleset-fails-to-load-at-boot) is in force), `missing` (no Housecarl table: flushed, or never applied) |
| `probation` | changes waiting to be confirmed, rolled back unless they are |
| `warnings` | codes of problems with the machine, as `hc status` shows them (`nftables_service`) |
For Checkmk, an HTTP check on the URL that expects status 200 (and,
if you like, the string `"firewall":"applied"`) covers housecarld being
down, the fallback and a missing ruleset. Reach it from where the input
rules let the monitor in (a rule for the Checkmk server's address and
port 8070).
## Checks ## Checks
`housecarld check` validates the configuration in the database against `housecarld check` validates the configuration in the database against
+32 -3
View File
@@ -21,6 +21,32 @@ 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 interface that comes up later): its rules match once it appears, and
`hc status` warns until then. `hc status` warns until then.
**VLANs.** A VLAN sub-interface is an interface like any other, by its
name: `enp2s0.20` (or whatever your network configuration calls it,
`vlan20`, `br0.20`). Tagged traffic arrives on the sub-interface, and
untagged traffic on the parent, so `enp2s0` alone doesn't cover
`enp2s0.20`. Give each VLAN its own zone to keep them apart:
```sh
hc zone add lan --interface enp2s0 # untagged
hc zone add iot --interface enp2s0.20
hc zone add guest --interface enp2s0.30
hc rule add forward --in iot --out wan
```
or one zone for the parent and all of its VLANs, when they should be
treated alike: `--interface enp2s0 --interface 'enp2s0.*'`. Interface
names are at most 15 characters (the kernel's limit). The flowtable
takes VLAN sub-interfaces too, by exact name
([flowtable.md](flowtable.md)).
**Renaming** a zone (`hc zone update dhcp --name lan`, or Edit on the
Zones page) changes every rule and NAT rule that names it, in the same
change: the history shows them together, and undoing it puts them all
back. Sets and services rename the same way, except dynamic sets: their
entries, and the API tokens and programs that fill them, go by their
name.
## Address sets ## Address sets
A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges, A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
@@ -183,7 +209,8 @@ first, marked "every packet"). So they can drop, reject or log, but not
accept: an accept there would let packets past every later rule. Let the accept: an accept there would let packets past every later rule. Let the
traffic in with a rule of its own. The flowtable's fast path skips the traffic in with a rule of its own. The flowtable's fast path skips the
forward chain for established connections, so with it on, forward forward chain for established connections, so with it on, forward
quotas and byte rates only count first packets (a warning says so). quotas and byte rates only count first packets (a warning says so;
[flowtable.md](flowtable.md)).
**Per-address limits group addresses**: each IPv4 address on its own, and **Per-address limits group addresses**: each IPv4 address on its own, and
IPv6 addresses by /64, since one device uses several (privacy addresses IPv6 addresses by /64, since one device uses several (privacy addresses
@@ -232,8 +259,10 @@ them. The antispoof lists can be changed with `--antispoof-v4` and
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
TCP and UDP connections forwarded between those devices skip the rules, TCP and UDP connections forwarded between those devices skip the rules,
which helps on a slow CPU. It needs exact device names. Counters of which helps when the CPU can't keep up with the line. It needs exact
forward rules then only count each connection's first packets. device names, and costs quotas, byte rates and traffic per device in the
forward chain their accuracy: see [flowtable.md](flowtable.md) for when
to use it, and when not.
## The zone matrix ## The zone matrix
+1 -1
View File
@@ -45,7 +45,7 @@ are unique per kind, ignoring case.
| `zone_interface` | error | not an interface name (up to 15 characters; a trailing `*` matches many) | | `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_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 | | `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 | | `no_external_zone` | warning | no zone is external: antispoofing, MSS clamping, the DHCPv6 client rule and port forwards without public addresses need one, and the boot fallback lets nothing but loopback through without one ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot)) |
## Sets and services ## Sets and services
+6 -2
View File
@@ -244,12 +244,16 @@ func newProblems(before, after []validate.Problem) []validate.Problem {
return out return out
} }
// ApplyCurrent puts the current configuration in force (at start). // ApplyCurrent puts the current configuration in force again: at start,
// and when a flowtable device appears. It holds the engine's lock, so a
// change can't be applied in between.
func (e *Engine) ApplyCurrent(ctx context.Context) error { func (e *Engine) ApplyCurrent(ctx context.Context) error {
if e.Firewall == nil { if e.Firewall == nil {
return nil return nil
} }
return e.Firewall.Apply(ctx, e.State()) e.mu.Lock()
defer e.mu.Unlock()
return e.Firewall.Apply(ctx, e.state.Clone())
} }
// Revert undoes a history entry, if the objects it changed are still as // Revert undoes a history entry, if the objects it changed are still as
+8
View File
@@ -63,6 +63,14 @@ func (h *Host) Up(name string) bool {
return h.up[name] return h.up[name]
} }
// Forget drops what was read, so the next question reads the interfaces
// again (after the kernel said one appeared or went).
func (h *Host) Forget() {
h.mu.Lock()
h.at = time.Time{}
h.mu.Unlock()
}
// Exists returns the set of interface names (nil if they can't be read). // Exists returns the set of interface names (nil if they can't be read).
func (h *Host) Exists() map[string]bool { func (h *Host) Exists() map[string]bool {
h.mu.Lock() h.mu.Lock()
+64 -47
View File
@@ -940,8 +940,10 @@ func (im *importer) run() {
order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5} order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5}
sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] }) sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] })
im.st.Settings.Builtins = model.Builtins{} im.st.Settings.Builtins = model.Builtins{}
found := map[string]map[string]string{} // builtin → chain → "prefix" or "later" found := map[string]map[string]string{} // builtin → chain → "prefix", "later" or "demoted"
var deferred []func() heads := map[string][]headRule{} // chain → the built-ins its rules start with, in order
var chains []string
rest := map[string][]*parsed{} // chain → the rules after its head
for _, b := range bases { for _, b := range bases {
rules := im.flatten(b.name, nil, 0) rules := im.flatten(b.name, nil, 0)
switch b.kind { switch b.kind {
@@ -954,9 +956,9 @@ func (im *importer) run() {
im.srcnat(p) im.srcnat(p)
} }
default: default:
chains = append(chains, b.kind)
prefix := true prefix := true
for _, p := range rules { for _, p := range rules {
p := p
if bi := im.builtinOf(b.kind, p); bi != "" { if bi := im.builtinOf(b.kind, p); bi != "" {
if found[bi] == nil { if found[bi] == nil {
found[bi] = map[string]string{} found[bi] = map[string]string{}
@@ -967,18 +969,25 @@ func (im *importer) run() {
} }
found[bi][b.kind] = where found[bi][b.kind] = where
if prefix { if prefix {
heads[b.kind] = append(heads[b.kind], headRule{bi, p})
continue continue
} }
} }
prefix = false prefix = false
kind := b.kind rest[b.kind] = append(rest[b.kind], p)
deferred = append(deferred, func() { im.rule(kind, p) })
} }
} }
} }
im.settleBuiltins(found) im.settleBuiltins(found, heads)
for _, f := range deferred { for _, chain := range chains {
f() for _, h := range heads[chain] {
if found[h.builtin][chain] == "demoted" {
im.rule(chain, h.p)
}
}
for _, p := range rest[chain] {
im.rule(chain, p)
}
} }
if len(im.flow) > 0 { if len(im.flow) > 0 {
im.st.Settings.FlowtableDevices = im.flow[0].Dev im.st.Settings.FlowtableDevices = im.flow[0].Dev
@@ -1239,10 +1248,20 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
return "" return ""
} }
// headRule is a rule at the head of a chain that is a built-in.
type headRule struct {
builtin string
p *parsed
}
// settleBuiltins turns on the built-ins found where they go, in every // settleBuiltins turns on the built-ins found where they go, in every
// chain they apply to; the others stay off, and the rules that looked // chain they apply to; the others stay off, and the rules that looked
// like them are kept as rules. // like them are kept as rules. Built-ins come before every rule, so once
func (im *importer) settleBuiltins(found map[string]map[string]string) { // one at a chain's head stays a rule, the ones after it there stay rules
// too ("demoted"), in their order: an accept moved ahead of a drop (ICMPv6
// ahead of dropping invalid packets) would let through what the drop
// stopped.
func (im *importer) settleBuiltins(found map[string]map[string]string, heads map[string][]headRule) {
b := &im.st.Settings.Builtins b := &im.st.Settings.Builtins
on := func(name string, chains ...string) bool { on := func(name string, chains ...string) bool {
for _, c := range chains { for _, c := range chains {
@@ -1252,14 +1271,36 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
} }
return true return true
} }
b.Loopback = on("loopback", model.ChainInput) for {
b.Established = on("established", model.ChainInput, model.ChainForward) b.Loopback = on("loopback", model.ChainInput)
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward) b.Established = on("established", model.ChainInput, model.ChainForward)
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput) b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
b.PingInternal = on("ping", model.ChainInput) b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput) b.PingInternal = on("ping", model.ChainInput)
b.MSSClamp = on("mss-clamp", model.ChainForward) b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
b.Antispoof = on("antispoof", model.ChainEarly) b.MSSClamp = on("mss-clamp", model.ChainForward)
b.Antispoof = on("antispoof", model.ChainEarly)
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}
changed := false
for chain, hs := range heads {
cut := false
for _, h := range hs {
// mss-clamp and flowtable decide nothing: their place
// doesn't matter.
if !kept[h.builtin] && h.builtin != "mss-clamp" && h.builtin != "flowtable" {
cut = true
}
if cut && found[h.builtin][chain] == "prefix" {
found[h.builtin][chain] = "demoted"
changed = true
}
}
}
if !changed {
break
}
}
if !b.Antispoof { if !b.Antispoof {
im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil
} else if im.st.Settings.AntispoofV6 == nil { } else if im.st.Settings.AntispoofV6 == nil {
@@ -1268,40 +1309,16 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
if b.ICMPv6 { if b.ICMPv6 {
im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)") im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)")
} }
// The ones found in one chain's head but not another's: explicit rules // The ones that stay rules, in their place: say which.
// in the chains where they were found. for _, chain := range []string{model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput} {
for name, chains := range found { for _, h := range heads[chain] {
for chain, where := range chains { if found[h.builtin][chain] == "demoted" {
if where != "prefix" { im.note("%s in the %s chain stays a rule, in its place (the built-in isn't in every chain it covers, or a rule before it stays a rule)", h.builtin, chain)
continue
}
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}[name]
if !kept {
im.note("%s in the %s chain stays a rule (it isn't in every chain the built-in covers, or comes after other rules)", name, chain)
im.builtinRule(name, chain)
} }
} }
} }
} }
// builtinRule writes a built-in that stays off as a rule of its chain.
func (im *importer) builtinRule(name, chain string) {
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain) - 1000}
switch name {
case "established":
r.CtState, r.Action = []string{"established", "related"}, model.ActionAccept
case "invalid":
r.CtState, r.Action = []string{"invalid"}, model.ActionDrop
case "loopback":
r.Raw, r.Action = `iifname "lo" accept`, model.ActionAccept
default:
im.note("built-in %s of the %s chain couldn't be kept; add it by hand", name, chain)
return
}
im.st.Rules = append(im.st.Rules, r)
}
func (im *importer) nextPos(chain string) float64 { func (im *importer) nextPos(chain string) float64 {
im.positions[chain]++ im.positions[chain]++
return im.positions[chain] return im.positions[chain]
+15 -2
View File
@@ -50,11 +50,24 @@ func TestImportRouter(t *testing.T) {
if s := st.Set("masq_to_internet"); s == nil { if s := st.Set("masq_to_internet"); s == nil {
t.Errorf("no set masq_to_internet (sets %v)", setNames(st)) t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
} }
// Built-ins: the ones at the head of their chains. // Built-ins: the ones at the head of their chains, up to the invalid
// drop, which stays a rule (below): the ICMP, ICMPv6 and DHCPv6
// accepts after it stay rules too, in their order, so nothing the
// drop stopped gets through ahead of it.
b := st.Settings.Builtins b := st.Settings.Builtins
if !b.Loopback || !b.Established || !b.ICMPv6 || !b.PingInternal || !b.DHCPv6Client || !b.MSSClamp || !b.Antispoof { if !b.Loopback || !b.Established || b.ICMPv6 || b.PingInternal || b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
t.Errorf("built-ins %+v", b) t.Errorf("built-ins %+v", b)
} }
var input []string
for _, r := range st.Rules {
if r.Chain == model.ChainInput {
input = append(input, strings.Join(r.CtState, ",")+"|"+strings.Join(r.Services, ",")+"|"+r.Raw)
}
}
if len(input) < 5 || input[0] != "invalid||" || !strings.HasPrefix(input[1], "|icmp/echo-reply,icmp/echo-request|") ||
!strings.Contains(input[2], "icmpv6/packet-too-big") || !strings.Contains(input[3], "ip6 hoplimit 255") || !strings.Contains(input[4], "udp/546") {
t.Errorf("input rules start %q", input)
}
// The forward chain's asymmetric-routing rule sits between the // The forward chain's asymmetric-routing rule sits between the
// established and invalid rules: invalid stays rules, in order. // established and invalid rules: invalid stays rules, in order.
if b.DropInvalid { if b.DropInvalid {
+96
View File
@@ -0,0 +1,96 @@
// Package linkwatch tells when the machine's network interfaces change:
// one appears, goes, or comes back (ppp0 when PPPoE reconnects). It
// listens to the kernel's rtnetlink link messages and calls back once
// they have been quiet for a moment, since one change brings several.
package linkwatch
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"time"
"golang.org/x/sys/unix"
)
// Settle is how long the link messages must be quiet before the callback.
const Settle = 2 * time.Second
// Watch calls changed after interfaces changed, until ctx ends. It returns
// an error only if it can't listen at all.
func Watch(ctx context.Context, changed func()) error {
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC|unix.SOCK_NONBLOCK, unix.NETLINK_ROUTE)
if err != nil {
return fmt.Errorf("opening a netlink socket: %w", err)
}
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK, Groups: unix.RTMGRP_LINK}); err != nil {
unix.Close(fd)
return fmt.Errorf("listening to link changes: %w", err)
}
// A non-blocking descriptor goes through Go's poller, so closing the
// file ends a Read in progress.
f := os.NewFile(uintptr(fd), "rtnetlink")
go func() {
<-ctx.Done()
f.Close()
}()
events := make(chan struct{}, 1)
go func() {
buf := make([]byte, 64<<10)
for {
n, err := f.Read(buf)
switch {
case err != nil && ctx.Err() != nil:
close(events)
return
case errors.Is(err, unix.ENOBUFS):
// Messages were lost: something changed anyway.
case err != nil:
time.Sleep(time.Second)
continue
case !links(buf[:n]):
continue
}
select {
case events <- struct{}{}:
default:
}
}
}()
var timer <-chan time.Time
for {
select {
case _, ok := <-events:
if !ok {
return nil
}
timer = time.After(Settle)
case <-timer:
timer = nil
changed()
}
}
}
// links reports whether a netlink datagram holds a link being added,
// changed or deleted.
func links(b []byte) bool {
for len(b) >= unix.NLMSG_HDRLEN {
size := int(binary.NativeEndian.Uint32(b[0:4]))
typ := binary.NativeEndian.Uint16(b[4:6])
if size < unix.NLMSG_HDRLEN || size > len(b) {
return false
}
if typ == unix.RTM_NEWLINK || typ == unix.RTM_DELLINK {
return true
}
size = (size + unix.NLMSG_ALIGNTO - 1) &^ (unix.NLMSG_ALIGNTO - 1)
if size >= len(b) {
return false
}
b = b[size:]
}
return false
}
+36
View File
@@ -0,0 +1,36 @@
package linkwatch
import (
"encoding/binary"
"testing"
"golang.org/x/sys/unix"
)
// msg is a netlink message of a type, with a payload of n bytes.
func msg(typ uint16, n int) []byte {
b := make([]byte, unix.NLMSG_HDRLEN+n)
binary.NativeEndian.PutUint32(b[0:4], uint32(len(b)))
binary.NativeEndian.PutUint16(b[4:6], typ)
return b
}
func TestLinks(t *testing.T) {
for _, c := range []struct {
name string
b []byte
want bool
}{
{"a new link", msg(unix.RTM_NEWLINK, 16), true},
{"a link going", msg(unix.RTM_DELLINK, 16), true},
{"an address", msg(unix.RTM_NEWADDR, 16), false},
{"a link after an address (padded)", append(msg(unix.RTM_NEWADDR, 13), append(make([]byte, 3), msg(unix.RTM_NEWLINK, 8)...)...), true},
{"truncated", msg(unix.RTM_NEWLINK, 16)[:10], false},
{"a length beyond the datagram", msg(unix.RTM_NEWLINK, 16)[:unix.NLMSG_HDRLEN+4], false},
{"empty", nil, false},
} {
if got := links(c.b); got != c.want {
t.Errorf("%s: %v", c.name, got)
}
}
}
+64 -5
View File
@@ -14,6 +14,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -209,6 +210,9 @@ type Table struct {
// Tables are all the machine's tables ("inet main"), when the whole // Tables are all the machine's tables ("inet main"), when the whole
// ruleset was read. // ruleset was read.
Tables []string Tables []string
// Fallback: the table is the boot fallback (render.Fallback), not a
// ruleset housecarld applied.
Fallback bool
} }
// QuotaUsage is what the quotas used: kept across reloads, and saved in // QuotaUsage is what the quotas used: kept across reloads, and saved in
@@ -306,6 +310,9 @@ func ParseTable(data []byte) (*Table, error) {
continue continue
} }
mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ") mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ")
if strings.HasPrefix(mark, render.FallbackMark+"-") {
t.Fallback = true
}
for _, e := range r.Expr { for _, e := range r.Expr {
var c struct { var c struct {
Counter *Counter `json:"counter"` Counter *Counter `json:"counter"`
@@ -411,6 +418,35 @@ type Firewall struct {
// restore is the quota usage saved before housecarld (or the machine) // restore is the quota usage saved before housecarld (or the machine)
// stopped, put back by the next apply. // stopped, put back by the next apply.
restore *QuotaUsage restore *QuotaUsage
// flowDevs are the flowtable's devices in the ruleset in force, by
// name, with their interface index then (0: left out, missing).
flowDevs map[string]int
}
// ifIndex is an interface's index, 0 if it doesn't exist (tests change it).
var ifIndex = func(name string) int {
if i, err := net.InterfaceByName(name); err == nil {
return i.Index
}
return 0
}
// FlowtableStale reports whether the flowtable in force no longer matches
// the machine: a device of st's flowtable appeared, went, or came back as
// a new interface (ppp0 after PPPoE reconnects; the kernel drops a
// device from the flowtable when it goes). Applying again fixes it.
func (f *Firewall) FlowtableStale(st *model.State) bool {
f.mu.Lock()
defer f.mu.Unlock()
if f.flowDevs == nil {
return false
}
for _, d := range st.Settings.FlowtableDevices {
if ifIndex(d) != f.flowDevs[d] {
return true
}
}
return false
} }
// RestoreQuotas has the next apply put this saved quota usage back where // RestoreQuotas has the next apply put this saved quota usage back where
@@ -482,6 +518,12 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
return err return err
} }
f.restore = nil f.restore = nil
f.flowDevs = map[string]int{}
for _, d := range st.Settings.FlowtableDevices {
if opt.Interfaces == nil || opt.Interfaces[d] {
f.flowDevs[d] = ifIndex(d)
}
}
if f.base == nil { if f.base == nil {
f.base, f.since = map[string]Counter{}, time.Now() f.base, f.since = map[string]Counter{}, time.Now()
} }
@@ -496,20 +538,37 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
return nil return nil
} }
// SaveBoot writes the ruleset of st for the boot unit, atomically. // SaveBoot writes the ruleset of st for the boot unit, and the fallback
// it loads if that fails (FallbackFile), atomically. The boot ruleset has
// no flowtable: it loads before the network comes up, when its devices
// (ppp0, wg0) don't exist yet and nftables would refuse the whole file;
// housecarld adds it once they do.
func (f *Firewall) SaveBoot(st *model.State) error { func (f *Firewall) SaveBoot(st *model.State) error {
if f.BootFile == "" { if f.BootFile == "" {
return nil return nil
} }
rs := render.Render(st, f.options())
tmp := f.BootFile + ".tmp"
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
return err return err
} }
if err := os.WriteFile(tmp, []byte(rs.Text), 0o640); err != nil { if err := writeAtomic(FallbackFile(f.BootFile), render.Fallback(st)); err != nil {
return err return err
} }
return os.Rename(tmp, f.BootFile) rs := render.Render(st, render.Options{Interfaces: map[string]bool{}})
return writeAtomic(f.BootFile, rs.Text)
}
// FallbackFile is where the fallback for a boot ruleset is saved:
// ruleset.nft's is ruleset-fallback.nft beside it.
func FallbackFile(boot string) string {
return strings.TrimSuffix(boot, ".nft") + "-fallback.nft"
}
func writeAtomic(path, text string) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(text), 0o640); err != nil {
return err
}
return os.Rename(tmp, path)
} }
// Table reads Housecarl's table back, with counters totalled since // Table reads Housecarl's table back, with counters totalled since
+108
View File
@@ -2,8 +2,10 @@ package nft
import ( import (
"context" "context"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -257,3 +259,109 @@ func TestDeviceCountsInContainer(t *testing.T) {
t.Errorf("after a reload: %+v", e) t.Errorf("after a reload: %+v", e)
} }
} }
// TestFlowtableStale: the flowtable in force goes stale when one of its
// devices appears, goes, or comes back as a new interface.
func TestFlowtableStale(t *testing.T) {
idx := map[string]int{"eth1": 3}
old := ifIndex
ifIndex = func(name string) int { return idx[name] }
t.Cleanup(func() { ifIndex = old })
exists := func() map[string]bool {
out := map[string]bool{}
for n, i := range idx {
out[n] = i > 0
}
return out
}
// "true" stands in for nft: every apply succeeds, and lists nothing.
fw := &Firewall{Runner: &Runner{Command: []string{"true"}}, Interfaces: exists}
st := model.NewState()
st.Settings.FlowtableDevices = []string{"ppp0", "eth1"}
if fw.FlowtableStale(st) {
t.Error("stale before anything was applied")
}
if err := fw.Apply(context.Background(), st); err != nil {
t.Fatal(err)
}
if fw.FlowtableStale(st) {
t.Error("stale right after applying")
}
idx["ppp0"] = 7 // PPPoE came up
if !fw.FlowtableStale(st) {
t.Error("ppp0 appeared, yet not stale")
}
fw.Apply(context.Background(), st)
idx["ppp0"] = 9 // and reconnected: a new interface by the same name
if !fw.FlowtableStale(st) {
t.Error("ppp0 came back as a new interface, yet not stale")
}
fw.Apply(context.Background(), st)
idx["eth9"] = 4 // not a flowtable device
if fw.FlowtableStale(st) {
t.Error("stale over an interface the flowtable doesn't use")
}
}
// TestBootInContainer: the boot ruleset loads although its flowtable
// devices don't exist yet, the fallback loads and is recognised, and
// housecarld's apply replaces it.
func TestBootInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-boot-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
nft := func(script string) error {
c := exec.Command("docker", "exec", "-i", name, "nft", "-f", "/dev/stdin")
c.Stdin = strings.NewReader(script)
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s", err, out)
}
return nil
}
dir := t.TempDir()
// ppp0 exists when housecarld saves, but not at boot.
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, BootFile: filepath.Join(dir, "ruleset.nft"),
Interfaces: func() map[string]bool { return map[string]bool{"ppp0": true, "eth0": true} }}
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"ppp0"}, External: true}, {ID: 2, Name: "lan", Interfaces: []string{"eth0"}}}
st.SNATs = []*model.SourceNAT{{ID: 3, Match: model.Match{OutZones: []string{"wan"}}}}
st.Settings.FlowtableDevices = []string{"ppp0", "eth0"}
if err := fw.SaveBoot(st); err != nil {
t.Fatal(err)
}
boot, _ := os.ReadFile(fw.BootFile)
if strings.Contains(string(boot), "flowtable") {
t.Errorf("the boot ruleset has a flowtable:\n%s", boot)
}
fallback, err := os.ReadFile(filepath.Join(dir, "ruleset-fallback.nft"))
if err != nil {
t.Fatal(err)
}
if err := nft(string(boot)); err != nil {
t.Fatalf("the boot ruleset doesn't load: %v", err)
}
ctx := context.Background()
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
t.Errorf("after the boot ruleset: %+v %v", tb, err)
}
if err := nft(string(fallback)); err != nil {
t.Fatalf("the fallback doesn't load: %v", err)
}
if tb, err := fw.Table(ctx); err != nil || !tb.Fallback {
t.Errorf("the fallback isn't recognised: %+v %v", tb, err)
}
fw.Interfaces = func() map[string]bool { return map[string]bool{"eth0": true} }
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply over the fallback: %v", err)
}
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
t.Errorf("the fallback stayed after an apply: %+v %v", tb, err)
}
}
+89
View File
@@ -0,0 +1,89 @@
package render
import (
"fmt"
"strings"
"housecarl/internal/model"
)
// FallbackMark starts the comment of every fallback rule ("hc:fallback-…"):
// how housecarld tells the fallback is in force.
const FallbackMark = "fallback"
// Fallback builds the ruleset housecarl-ruleset.service loads at boot when
// the saved one fails to: nothing new gets in from the external zones'
// interfaces, everything else is let through, and IPv4 leaving an
// external zone is masqueraded (when any source NAT is configured), so the
// inside keeps working until housecarld puts the real ruleset back. With
// no external zone it can't tell which interface faces the internet, and
// lets nothing but loopback through.
//
// It is deliberately small: no sets, flowtable, meters or quotas, and
// interfaces by name only, so it loads before they exist.
func Fallback(st *model.State) string {
var b strings.Builder
line := func(format string, args ...any) {
fmt.Fprintf(&b, format, args...)
b.WriteByte('\n')
}
// rule writes a counted rule: what it matches, then its verdict.
rule := func(name, match, verdict string) {
line("\t\t%s counter %s comment \"hc:%s-%s\"", match, verdict, FallbackMark, name)
}
ext := st.ExternalInterfaces()
line("# Housecarl's fallback: loaded at boot only if ruleset.nft fails to load,")
line("# until housecarld starts and puts the real ruleset back.")
line("table inet %s {}", Table)
line("delete table inet %s", Table)
line("table inet %s {", Table)
if len(ext) == 0 {
// Nothing says which interface faces the internet: close it all.
for _, c := range []string{"input", "forward"} {
line("\tchain %s {", c)
line("\t\ttype filter hook %s priority filter; policy drop;", c)
line("\t\tiifname \"lo\" accept")
line("\t\tcounter comment \"hc:%s-%s\"", FallbackMark, c)
line("\t}")
}
line("}")
return b.String()
}
e := ifaces(ext)
line("\tchain input {")
line("\t\ttype filter hook input priority filter; policy accept;")
line("\t\tiifname \"lo\" accept")
line("\t\tct state established,related accept")
rule("icmpv6", fmt.Sprintf("iifname %s icmpv6 type { %s, echo-request, echo-reply }", e, icmpv6Errors), "accept")
rule("icmpv6-nd", fmt.Sprintf("iifname %s icmpv6 type { %s } ip6 hoplimit 255", e, icmpv6ND), "accept")
rule("icmpv6-mld", fmt.Sprintf("iifname %s ip6 saddr fe80::/10 icmpv6 type { %s }", e, icmpv6MLD), "accept")
rule("dhcpv6-client", fmt.Sprintf("iifname %s ip6 saddr fe80::/10 udp dport 546", e), "accept")
rule("input", "iifname "+e, "drop")
line("\t}")
line("\tchain forward {")
line("\t\ttype filter hook forward priority filter; policy accept;")
if st.Settings.Builtins.MSSClamp {
line("\t\toifname %s tcp flags syn tcp option maxseg size set rt mtu", e)
}
line("\t\tct state established,related accept")
rule("forward", "iifname "+e, "drop")
line("\t}")
if masquerades(st) {
line("\tchain srcnat {")
line("\t\ttype nat hook postrouting priority srcnat; policy accept;")
rule("masquerade", "meta nfproto ipv4 oifname "+e, "masquerade")
line("\t}")
}
line("}")
return b.String()
}
// masquerades reports whether any source NAT is configured.
func masquerades(st *model.State) bool {
for _, x := range st.SNATs {
if !x.Disabled {
return true
}
}
return false
}
+75
View File
@@ -0,0 +1,75 @@
package render
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"housecarl/internal/model"
)
func TestFallback(t *testing.T) {
st := router()
text := Fallback(st)
for _, want := range []string{
"delete table inet housecarl",
"type filter hook input priority filter; policy accept;",
`iifname "wan" counter drop comment "hc:fallback-input"`,
`iifname "wan" counter drop comment "hc:fallback-forward"`,
`iifname "wan" ip6 saddr fe80::/10 udp dport 546 counter accept`,
`ip6 hoplimit 255 counter accept`,
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu`,
`meta nfproto ipv4 oifname "wan" counter masquerade comment "hc:fallback-masquerade"`,
} {
if !strings.Contains(text, want) {
t.Errorf("no %q in\n%s", want, text)
}
}
// Nothing that could fail to load before the network is up.
for _, not := range []string{"flowtable", "@", "\tset ", "enp2s0", "wg0"} {
if strings.Contains(text, not) {
t.Errorf("%q in the fallback:\n%s", not, text)
}
}
// Established connections are accepted before the drops.
if strings.Index(text, "ct state established,related accept") > strings.Index(text, "hc:fallback-input") {
t.Errorf("established after the drop:\n%s", text)
}
// No source NAT: no masquerade.
st.SNATs = nil
if text := Fallback(st); strings.Contains(text, "masquerade") {
t.Errorf("masquerade without source NAT:\n%s", text)
}
// No external zone: everything but loopback is dropped.
for _, z := range st.Zones {
z.External = false
}
text = Fallback(st)
if strings.Count(text, "policy drop;") != 2 || strings.Contains(text, "policy accept") || strings.Contains(text, "established") {
t.Errorf("no external zone:\n%s", text)
}
}
func TestFallbackPassesNft(t *testing.T) {
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
if len(cmd) == 0 {
t.Skip("HOUSECARL_NFT not set")
}
closed := router()
for _, z := range closed.Zones {
z.External = false
}
wild := router()
wild.Zones = append(wild.Zones, &model.Zone{ID: 99, Name: "lte", Interfaces: []string{"wwan*"}, External: true})
for name, st := range map[string]*model.State{"router": router(), "closed": closed, "wildcard": wild} {
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(Fallback(st))
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("%s: nft -c: %v\n%s\n%s", name, err, out, Fallback(st))
}
}
}
+9 -1
View File
@@ -244,7 +244,15 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
} }
stats := router() stats := router()
stats.Settings.StatsPerDevice = true stats.Settings.StatsPerDevice = true
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats} { // VLAN sub-interfaces: zones of one VLAN, of all of a parent's, and
// the flowtable on one.
vlan := router()
vlan.Zones = append(vlan.Zones, &model.Zone{ID: 900, Name: "iot", Interfaces: []string{"enp2s0.20"}},
&model.Zone{ID: 901, Name: "vlans", Interfaces: []string{"enp3s0.*"}})
vlan.Rules = append(vlan.Rules, &model.Rule{ID: 902, Chain: model.ChainForward, Position: 99, Action: model.ActionAccept,
Match: model.Match{InZones: []string{"iot", "vlans"}, OutZones: []string{"wan"}}})
vlan.Settings.FlowtableDevices = []string{"lo", "enp2s0.20"}
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats, "vlan": vlan} {
q, qs := quotaOptions(st) q, qs := quotaOptions(st)
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs, rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}}}) DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}}})
+41
View File
@@ -381,6 +381,47 @@ func usedBy(st *model.State, kind string, o model.Object) string {
return "" return ""
} }
// renameRefs makes every object that refers to a zone, set or service by
// its old name use the new one: renaming it changes them in the same
// change, so the history shows (and undoes) them together.
func renameRefs(st *model.State, kind, old, now string) {
fix := func(list []string) {
for i, x := range list {
if strings.EqualFold(x, old) {
list[i] = now
}
}
}
match := func(m *model.Match) {
switch kind {
case model.KindZone:
fix(m.InZones)
fix(m.OutZones)
case model.KindSet:
fix(m.Sources)
fix(m.Destinations)
case model.KindService:
fix(m.Services)
}
}
for _, x := range st.Rules {
match(&x.Match)
}
for _, x := range st.SNATs {
match(&x.Match)
}
for _, x := range st.Redirects {
match(&x.Match)
}
if kind == model.KindSet {
for _, f := range st.Forwards {
fix(f.Sources)
fix(f.PublicAddresses)
fix(f.HairpinSources)
}
}
}
// moveObject reorders a rule or NAT rule (moveOp). // moveObject reorders a rule or NAT rule (moveOp).
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r) kind, err := pathKind(r)
+70
View File
@@ -0,0 +1,70 @@
package server
import (
"net/http"
"sync"
"time"
"housecarl/internal/version"
)
// fallbackInForce is what status and /healthz say while the boot fallback
// (render.Fallback) is in force.
const fallbackInForce = "the boot fallback is in force, not Housecarl's ruleset: the saved ruleset failed to load at boot (journalctl -u housecarl-ruleset), and housecarld couldn't apply it since; it lets nothing new in from external zones (with none, nothing but loopback)"
// health is GET /healthz, for monitoring (Checkmk, a load balancer). It
// needs no authentication, so it says only what a monitor needs, and reads
// the ruleset at most every few seconds however often it is asked.
type health struct {
OK bool `json:"ok"`
Version string `json:"version"`
Firewall string `json:"firewall"` // applied, unconfigured, fallback, missing
// Probation: changes waiting to be confirmed, rolled back otherwise.
Probation int `json:"probation"`
// Warnings are the codes of the machine's problems (nftables_service).
Warnings []string `json:"warnings"`
}
const healthTTL = 5 * time.Second
type healthCache struct {
mu sync.Mutex
at time.Time
out health
}
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
c := &s.health
c.mu.Lock()
if time.Since(c.at) >= healthTTL {
c.out, c.at = s.checkHealth(r), time.Now()
}
out := c.out
c.mu.Unlock()
code := http.StatusOK
if !out.OK {
code = http.StatusServiceUnavailable
}
writeJSON(w, code, out)
}
func (s *Server) checkHealth(r *http.Request) health {
out := health{Version: version.String(), Warnings: []string{}}
t, err := s.fw.Table(r.Context())
switch {
case err != nil && s.eng.State().Empty():
out.Firewall, out.OK = "unconfigured", true
case err != nil:
out.Firewall = "missing"
case t.Fallback:
out.Firewall = "fallback"
default:
out.Firewall, out.OK = "applied", true
}
pr := s.probationStatus()
out.Probation = len(pr.Pending) + pr.Takeovers
for _, p := range s.hostProblems(r.Context(), nil) {
out.Warnings = append(out.Warnings, p.Code)
}
return out
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"housecarl/internal/api"
)
func TestHealthz(t *testing.T) {
e := newEnv(t)
link := filepath.Join(t.TempDir(), "nftables.service")
e.srv.unitLinks = []string{link}
get := func() (int, health) {
t.Helper()
e.srv.health.at = time.Time{} // no caching between the steps
resp, err := http.Get(e.ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var h health
json.NewDecoder(resp.Body).Decode(&h)
return resp.StatusCode, h
}
// Nothing configured yet: nothing to be in force.
if code, h := get(); code != 200 || !h.OK || h.Firewall != "unconfigured" || h.Version == "" {
t.Errorf("unconfigured: %d %+v", code, h)
}
if code := e.call(e.token, "POST", "/zones", map[string]any{"name": "wan", "interfaces": []string{"wan"}, "external": true}, nil); code != 201 {
t.Fatalf("creating a zone: %d", code)
}
if code, h := get(); code != 200 || h.Firewall != "applied" || h.Probation != 1 || len(h.Warnings) != 0 {
t.Errorf("applied, on probation: %d %+v", code, h)
}
os.WriteFile(link, nil, 0o644)
if _, h := get(); strings.Join(h.Warnings, ",") != "nftables_service" {
t.Errorf("warnings %v", h.Warnings)
}
e.fw.mu.Lock()
e.fw.fallback = true
e.fw.mu.Unlock()
if code, h := get(); code != 503 || h.OK || h.Firewall != "fallback" {
t.Errorf("fallback: %d %+v", code, h)
}
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
if st.Applied || !strings.Contains(st.ApplyError, "boot fallback") {
t.Errorf("status with the fallback: applied %v, %q", st.Applied, st.ApplyError)
}
// Asked often, the ruleset is read once per few seconds.
e.fw.mu.Lock()
e.fw.fallback = false
e.fw.mu.Unlock()
resp, _ := http.Get(e.ts.URL + "/healthz")
resp.Body.Close()
if resp.StatusCode != 503 {
t.Errorf("not cached: %d", resp.StatusCode)
}
}
+3 -1
View File
@@ -37,10 +37,12 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
for _, k := range model.Kinds { for _, k := range model.Kinds {
out.Counts[k] = len(st.Objects(k)) out.Counts[k] = len(st.Objects(k))
} }
if _, err := s.fw.Table(r.Context()); err != nil && st.Empty() { if t, err := s.fw.Table(r.Context()); err != nil && st.Empty() {
out.Unconfigured = true out.Unconfigured = true
} else if err != nil { } else if err != nil {
out.ApplyError = err.Error() out.ApplyError = err.Error()
} else if t.Fallback {
out.ApplyError = fallbackInForce
} else { } else {
out.Applied = true out.Applied = true
} }
+11
View File
@@ -94,10 +94,21 @@ func updateOp(st *model.State, op *model.Op) (int64, error) {
return 0, err return 0, err
} }
setID(next, id) setID(next, id)
if old, now := o.Label(), next.Label(); old != now && isNamed(op.Kind) {
if set, ok := o.(*model.AddressSet); ok && set.Dynamic {
return 0, conflict("set %s is dynamic: its entries, and the API tokens and programs that fill it, go by its name; make a new set instead", old)
}
renameRefs(st, op.Kind, old, now)
}
data, _ := json.Marshal(next) data, _ := json.Marshal(next)
return id, st.Put(op.Kind, id, data) return id, st.Put(op.Kind, id, data)
} }
// isNamed reports whether other objects refer to a kind by name.
func isNamed(kind string) bool {
return kind == model.KindZone || kind == model.KindSet || kind == model.KindService
}
func deleteOp(st *model.State, op *model.Op) error { func deleteOp(st *model.State, op *model.Op) error {
o, err := findObject(st, op.Kind, op.Ref) o, err := findObject(st, op.Kind, op.Ref)
if err != nil { if err != nil {
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"strings"
"testing"
"housecarl/internal/api"
)
// TestRename: renaming a zone, set or service changes what refers to it,
// in the same change, which undoes as one.
func TestRename(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 0
post := func(path string, body map[string]any) {
t.Helper()
if code := e.call(e.token, "POST", path, body, nil); code != 201 {
t.Fatalf("POST %s: %d", path, code)
}
}
post("/zones", map[string]any{"name": "wan", "interfaces": []string{"wan"}, "external": true})
post("/zones", map[string]any{"name": "dhcp", "interfaces": []string{"eth1"}})
post("/sets", map[string]any{"name": "trusted_ips", "entries": []string{"10.0.10.0/24"}})
post("/sets", map[string]any{"name": "guests", "dynamic": true})
post("/services", map[string]any{"name": "jarl", "entries": []string{"tcp/8053"}})
post("/rules", map[string]any{"chain": "forward", "in_zones": []string{"dhcp"}, "out_zones": []string{"wan"}, "sources": []string{"trusted_ips"}})
post("/rules", map[string]any{"chain": "input", "in_zones": []string{"dhcp"}, "services": []string{"jarl"}})
post("/snats", map[string]any{"out_zones": []string{"wan"}, "sources": []string{"trusted_ips"}})
post("/forwards", map[string]any{"name": "jf", "ports": "8096", "to": "10.0.10.57", "public_addresses": []string{"217.155.8.194"}, "sources": []string{"trusted_ips"}})
var res api.MutationResponse
if code := e.call(e.token, "PATCH", "/zones/dhcp", map[string]any{"name": "lan"}, &res); code != 200 {
t.Fatalf("renaming the zone: %d %+v", code, res)
}
zoneRename := res.HistoryID
e.call(e.token, "PATCH", "/sets/trusted_ips", map[string]any{"name": "trusted"}, &res)
setRename := res.HistoryID
e.call(e.token, "PATCH", "/services/jarl", map[string]any{"name": "jarl-api"}, &res)
serviceRename := res.HistoryID
st := e.eng.State()
if st.Zone("dhcp") != nil || st.Zone("lan") == nil {
t.Fatalf("zones %+v", st.Zones)
}
for _, r := range st.Rules {
if strings.Join(r.InZones, ",") != "lan" {
t.Errorf("rule %d in zones %v", r.ID, r.InZones)
}
if r.Chain == "forward" && strings.Join(r.Sources, ",") != "trusted" {
t.Errorf("rule %d sources %v", r.ID, r.Sources)
}
if r.Chain == "input" && strings.Join(r.Services, ",") != "jarl-api" {
t.Errorf("rule %d services %v", r.ID, r.Services)
}
}
if s := st.SNATs[0].Sources; strings.Join(s, ",") != "trusted" {
t.Errorf("snat sources %v", s)
}
if s := st.Forwards[0].Sources; strings.Join(s, ",") != "trusted" {
t.Errorf("forward sources %v", s)
}
if ps := e.eng.Problems(); len(ps) != 0 {
for _, p := range ps {
if p.Severity == "error" {
t.Errorf("problem after renaming: %+v", p)
}
}
}
// Undoing the renames (newest first) puts the references back too.
for _, id := range []int64{serviceRename, setRename, zoneRename} {
if code := e.call(e.token, "POST", "/history/"+itoa(id)+"/revert", nil, nil); code != 200 {
t.Fatalf("undo %d: %d", id, code)
}
}
st = e.eng.State()
for _, r := range st.Rules {
if strings.Join(r.InZones, ",") != "dhcp" || r.Chain == "forward" && r.Sources[0] != "trusted_ips" || r.Chain == "input" && r.Services[0] != "jarl" {
t.Errorf("after undo: rule %+v", r.Match)
}
}
// A dynamic set keeps its name: tokens and programs go by it.
if code := e.call(e.token, "PATCH", "/sets/guests", map[string]any{"name": "visitors"}, nil); code != 409 {
t.Errorf("renaming a dynamic set: %d", code)
}
}
+1 -4
View File
@@ -10,15 +10,12 @@ import (
"time" "time"
"housecarl/internal/api" "housecarl/internal/api"
"housecarl/internal/version"
) )
func (s *Server) routes() { func (s *Server) routes() {
m := s.mux m := s.mux
p := api.Prefix p := api.Prefix
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { m.HandleFunc("GET /healthz", s.healthz)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
})
// Authentication, users and tokens // Authentication, users and tokens
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login)) m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout)) m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
+1
View File
@@ -90,6 +90,7 @@ type Server struct {
srvs []*http.Server srvs []*http.Server
probation probation probation probation
health healthCache
logs *logRing logs *logRing
// unitLinks are where an enabled nftables.service shows (tests change // unitLinks are where an enabled nftables.service shows (tests change
// them). // them).
+2 -1
View File
@@ -40,6 +40,7 @@ type fakeFW struct {
resets []string resets []string
traced string traced string
traceLines []string traceLines []string
fallback bool // the boot fallback is in force
} }
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil } func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
@@ -64,7 +65,7 @@ func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
if len(f.applied) == 0 { if len(f.applied) == 0 {
return nil, errors.New("No such file or directory: list table inet housecarl") return nil, errors.New("No such file or directory: list table inet housecarl")
} }
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements}, nil return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback}, nil
} }
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error { func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
f.mu.Lock() f.mu.Lock()
+2 -2
View File
@@ -223,8 +223,8 @@ func (c *checker) zones() {
} }
} }
} }
if len(c.st.Zones) > 0 && !external { if !c.st.Empty() && !external {
c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses") c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses; and if the saved ruleset fails to load at boot, the fallback can't tell which interface faces the internet, and lets nothing but loopback through")
} }
} }
+26
View File
@@ -165,3 +165,29 @@ func TestHostWarnings(t *testing.T) {
} }
} }
} }
func TestNoExternalZone(t *testing.T) {
st := base()
if got := codes(Check(st, nil)); strings.Contains(got, "no_external_zone") {
t.Errorf("an external zone, yet: [%s]", got)
}
st.Zones[0].External = false
if got := codes(Check(st, nil)); !strings.Contains(got, "no_external_zone") {
t.Errorf("no external zone, and no warning: [%s]", got)
}
// An empty configuration applies nothing, so has no fallback either.
if got := codes(Check(&model.State{Settings: model.DefaultSettings()}, nil)); strings.Contains(got, "no_external_zone") {
t.Errorf("empty configuration: [%s]", got)
}
}
func TestVLANInterfaces(t *testing.T) {
st := base()
st.Zones[1].Interfaces = []string{"enp2s0.10", "enp2s0.4094", "br0.20", "enp3s0.*"}
st.Settings.FlowtableDevices = []string{"enp2s0.10"}
for _, p := range Check(st, nil) {
if p.Severity == Error {
t.Errorf("VLAN interfaces refused: %+v", p)
}
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ export async function render(el) {
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."), field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
field("Antispoof IPv4", v4), field("Antispoof IPv4", v4),
field("Antispoof IPv6", v6), field("Antispoof IPv6", v6),
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path). Exact names; none: off."), field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."),
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."), field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
h("label"), manual, h("label"), manual,
field("Statistics", perDevice)), field("Statistics", perDevice)),
+80
View File
@@ -355,6 +355,53 @@ sleep 1
run "the log shows it" 0 H log run "the log shows it" 0 H log
contains "hc input #.* drop.*198.18.1.10:.*198.18.1.2:8070" contains "hc input #.* drop.*198.18.1.10:.*198.18.1.2:8070"
# ---- the flowtable, and devices that appear later
run "a flowtable device that doesn't exist yet" 0 H settings set --flowtable wan --flowtable enp2s0 --flowtable hcft0
contains "flowtable_device_missing"
run "the flowtable has the devices that exist" 0 R nft list flowtables
contains "devices = { enp2s0, wan }"
run "forwarded through the flowtable" 0 connect net $WAN4 8096
contains "jellyfin"
run "the device appears" 0 R ip link add hcft0 type veth peer name hcft1
R ip link set hcft0 up
sleep 4
run "applied again with it" 0 R nft list flowtables
contains "hcft0"
run "logged" 0 R grep -c "interfaces changed: applied the ruleset again" /w/srv.log
run "it comes back as a new interface" 0 R sh -c 'ip link del hcft0 && ip link add hcft0 type veth peer name hcft1 && ip link set hcft0 up'
sleep 4
run "applied again" 0 R sh -c 'grep -c "interfaces changed: applied the ruleset again" /w/srv.log | grep -qx 2'
run "the flowtable still has it" 0 R nft list flowtables
contains "hcft0"
# ---- VLAN sub-interfaces: tagged traffic between the router and the client
R sh -c 'ip link add link enp2s0 name enp2s0.30 type vlan id 30 && ip addr add 10.88.30.1/24 dev enp2s0.30 && ip link set enp2s0.30 up'
docker exec ${P}-client sh -c 'ip link add link eth0 name eth0.30 type vlan id 30 && ip addr add 10.88.30.20/24 dev eth0.30 && ip link set eth0.30 up'
run "a zone of a VLAN" 0 H zone add iot --interface enp2s0.30
run "nothing from the VLAN to the API yet" 1 connect client 10.88.30.1 8070
VR=$(H -o id rule add input --in iot --service tcp/8070 --comment "e2e vlan")
run "a rule for the VLAN's zone" 0 test -n "$VR"
run "the VLAN reaches the API" 0 connect client 10.88.30.1 8070
run "the rule counted it" 0 H rule list input
contains "e2e vlan"
run "the flowtable on the VLAN" 0 H settings set --flowtable wan --flowtable enp2s0 --flowtable hcft0 --flowtable enp2s0.30
run "attached" 0 R nft list flowtables
contains "enp2s0.30"
run "the VLAN rule goes" 0 H rule delete -y "$VR"
run "the VLAN zone goes" 0 H zone delete -y iot
R ip link del enp2s0.30
# ---- renaming a zone others refer to
run "rename the imported dhcp zone" 0 H zone update dhcp --name lan
run "its rules follow" 0 H rule list input
contains "lan"
not_contains "dhcp "
run "the ruleset is unchanged by it" 0 R nft list chain inet housecarl input
contains 'iifname "enp2s0" udp dport 67'
# ---- restart and boot # ---- restart and boot
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1 docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
@@ -369,6 +416,39 @@ run "nothing forwarded after a flush" 1 connect net $WAN4 8096
run "the boot unit's command restores it" 0 R nft -f /w/data/ruleset.nft run "the boot unit's command restores it" 0 R nft -f /w/data/ruleset.nft
run "forwarded again" 0 connect net $WAN4 8096 run "forwarded again" 0 connect net $WAN4 8096
contains "jellyfin" contains "jellyfin"
run "the boot ruleset has no flowtable (its devices may not exist yet)" 1 R grep -q flowtable /w/data/ruleset.nft
run "healthz" 0 R curl -sf http://127.0.0.1:8070/healthz
contains '"firewall":"applied"'
# ---- the boot fallback: what the boot unit runs, with the lab's paths
UNIT=$(sed -n 's/^ExecStart=//p' "$ROOT/deploy/systemd/housecarl-ruleset.service" | sed 's|/var/lib/housecarl|/w/data|g; s|/usr/sbin/nft|nft|g')
run "the fallback was saved" 0 R test -s /w/data/ruleset-fallback.nft
R cp /w/data/ruleset.nft /w/ruleset.good
R sh -c 'echo "this is not nftables" >> /w/data/ruleset.nft'
R nft flush ruleset
run "the boot unit fails, and loads the fallback" 1 R sh -c "$UNIT"
contains "the saved ruleset failed to load"
run "the fallback is in force" 0 R nft list table inet housecarl
contains "hc:fallback-input"
run "nothing new gets in from the internet" 1 connect net $WAN4 8096
run "not to the firewall either" 1 connect net $WAN4 8070
: > "$WORK/net.log"
run "the LAN still reaches the internet" 0 connect client $NET4 9000
sleep 1
run "masqueraded" 0 grep -q "$WAN4" "$WORK/net.log"
run "healthz says so" 0 R sh -c 'curl -s -w " %{http_code}" http://127.0.0.1:8070/healthz'
contains '"firewall":"fallback"'
contains " 503"
run "status says so" 0 H status
contains "NOT in force: the boot fallback"
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
run "housecarld starts" 0 start
run "and puts the real ruleset back" 0 connect net $WAN4 8096
contains "jellyfin"
run "and saves a good boot ruleset" 0 R nft -c -f /w/data/ruleset.nft
run "healthz is happy again" 0 R curl -sf http://127.0.0.1:8070/healthz
contains '"firewall":"applied"'
# ---- backup # ---- backup