Compare commits
2
Commits
v0.1.0-rc.7
...
dev-0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17f5cd9f0 | ||
|
|
9d766b9823 |
No files matched your search
+34
-1
@@ -6,6 +6,8 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.0] - 2026-10-19
|
||||
|
||||
The first version of Housecarl, a firewall manager for nftables, built
|
||||
the way Jarl and Steward are.
|
||||
|
||||
@@ -66,7 +68,38 @@ the way Jarl and Steward are.
|
||||
open.
|
||||
- **At boot:** the confirmed ruleset is loaded by
|
||||
`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
|
||||
(it loads `/etc/nftables.conf` at boot, and flushes every table when
|
||||
stopped), and while the table an import came from still applies.
|
||||
|
||||
@@ -37,7 +37,8 @@ table of its own.
|
||||
* **The rules every firewall needs**, built in and each one switchable:
|
||||
loopback, established connections, invalid packets, the ICMPv6 that
|
||||
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
|
||||
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,
|
||||
@@ -56,7 +57,8 @@ table of its own.
|
||||
the history of every change, field by field, with undo.
|
||||
* **Its own table only.** Docker, Incus and libvirt keep theirs. The
|
||||
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
|
||||
themselves, and a token can be limited to one set: that is how
|
||||
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
|
||||
* [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
|
||||
* [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
|
||||
* [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
|
||||
@@ -115,11 +118,11 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
|
||||
## 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
|
||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||
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),
|
||||
bandwidth limits and quotas, a web UI, REST API and CLI.
|
||||
|
||||
|
||||
@@ -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} {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/hostif"
|
||||
"housecarl/internal/jarl"
|
||||
"housecarl/internal/linkwatch"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/nflog"
|
||||
"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 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 srv.RunQuotas(ctx)
|
||||
srv.SetConntrack(conntrack.Client{})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 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.
|
||||
[Unit]
|
||||
Description=Housecarl firewall ruleset
|
||||
@@ -15,7 +18,8 @@ ConditionPathExists=/var/lib/housecarl/ruleset.nft
|
||||
[Service]
|
||||
Type=oneshot
|
||||
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]
|
||||
WantedBy=sysinit.target
|
||||
+1
-1
@@ -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 /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)) |
|
||||
|
||||
+2
-2
@@ -148,7 +148,7 @@ by name.
|
||||
|---|---|
|
||||
| `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 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 |
|
||||
| `set list`, `set show SET` | |
|
||||
| `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | |
|
||||
@@ -208,7 +208,7 @@ See [nat.md](nat.md).
|
||||
| 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] [--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
|
||||
|
||||
|
||||
@@ -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.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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -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
|
||||
confirmed one.
|
||||
|
||||
It fails closed. A fallback saved beside it (`ruleset-fallback.nft`,
|
||||
`render.Fallback`) is loaded if it doesn't: no sets, no flowtable,
|
||||
interfaces by name only, so there is little in it to fail. It keeps
|
||||
anything new from the external zones out and lets the inside work,
|
||||
which is as far as a ruleset can go without knowing the rules; a
|
||||
fallback that let everything in would leave a router open on the
|
||||
internet, and one that let nothing in would lock out the person who has
|
||||
to fix it. The boot ruleset has no flowtable either: nftables refuses a
|
||||
flowtable naming a device that doesn't exist yet (on Debian 13's
|
||||
kernel), and before the network is up, `ppp0` doesn't. housecarld
|
||||
watches the kernel's link messages (internal/linkwatch) and applies
|
||||
again when a flowtable device appears, or comes back as a new interface.
|
||||
|
||||
## The drop log
|
||||
|
||||
Rules with logging on, and the policies (Settings), send packets to
|
||||
|
||||
@@ -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
@@ -50,8 +50,11 @@ Import a ruleset.
|
||||
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.
|
||||
a rule where it was, so the ruleset does what it did. Since built-ins
|
||||
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
|
||||
merged into one. The forward-chain accepts that let them through
|
||||
become part of the port forward, and a masquerade for inside sources
|
||||
|
||||
+75
-2
@@ -71,14 +71,63 @@ sudo hc history undo 42
|
||||
|
||||
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:
|
||||
loads before the network comes up, so before PPPoE, WireGuard or DHCP
|
||||
bring up their interfaces. 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.
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
`housecarld check` validates the configuration in the database against
|
||||
|
||||
+32
-3
@@ -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
|
||||
`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
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
which helps when the CPU can't keep up with the line. It needs exact
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -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_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 |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -244,12 +244,16 @@ func newProblems(before, after []validate.Problem) []validate.Problem {
|
||||
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 {
|
||||
if e.Firewall == 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
|
||||
|
||||
@@ -63,6 +63,14 @@ func (h *Host) Up(name string) bool {
|
||||
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).
|
||||
func (h *Host) Exists() map[string]bool {
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -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}
|
||||
sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] })
|
||||
im.st.Settings.Builtins = model.Builtins{}
|
||||
found := map[string]map[string]string{} // builtin → chain → "prefix" or "later"
|
||||
var deferred []func()
|
||||
found := map[string]map[string]string{} // builtin → chain → "prefix", "later" or "demoted"
|
||||
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 {
|
||||
rules := im.flatten(b.name, nil, 0)
|
||||
switch b.kind {
|
||||
@@ -954,9 +956,9 @@ func (im *importer) run() {
|
||||
im.srcnat(p)
|
||||
}
|
||||
default:
|
||||
chains = append(chains, b.kind)
|
||||
prefix := true
|
||||
for _, p := range rules {
|
||||
p := p
|
||||
if bi := im.builtinOf(b.kind, p); bi != "" {
|
||||
if found[bi] == nil {
|
||||
found[bi] = map[string]string{}
|
||||
@@ -967,18 +969,25 @@ func (im *importer) run() {
|
||||
}
|
||||
found[bi][b.kind] = where
|
||||
if prefix {
|
||||
heads[b.kind] = append(heads[b.kind], headRule{bi, p})
|
||||
continue
|
||||
}
|
||||
}
|
||||
prefix = false
|
||||
kind := b.kind
|
||||
deferred = append(deferred, func() { im.rule(kind, p) })
|
||||
rest[b.kind] = append(rest[b.kind], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
im.settleBuiltins(found)
|
||||
for _, f := range deferred {
|
||||
f()
|
||||
im.settleBuiltins(found, heads)
|
||||
for _, chain := range chains {
|
||||
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 {
|
||||
im.st.Settings.FlowtableDevices = im.flow[0].Dev
|
||||
@@ -1239,10 +1248,20 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
|
||||
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
|
||||
// chain they apply to; the others stay off, and the rules that looked
|
||||
// like them are kept as rules.
|
||||
func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
||||
// like them are kept as rules. Built-ins come before every rule, so once
|
||||
// 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
|
||||
on := func(name string, chains ...string) bool {
|
||||
for _, c := range chains {
|
||||
@@ -1252,14 +1271,36 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
b.Loopback = on("loopback", model.ChainInput)
|
||||
b.Established = on("established", model.ChainInput, model.ChainForward)
|
||||
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
|
||||
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
|
||||
b.PingInternal = on("ping", model.ChainInput)
|
||||
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
|
||||
b.MSSClamp = on("mss-clamp", model.ChainForward)
|
||||
b.Antispoof = on("antispoof", model.ChainEarly)
|
||||
for {
|
||||
b.Loopback = on("loopback", model.ChainInput)
|
||||
b.Established = on("established", model.ChainInput, model.ChainForward)
|
||||
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
|
||||
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
|
||||
b.PingInternal = on("ping", model.ChainInput)
|
||||
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
|
||||
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 {
|
||||
im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, 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 {
|
||||
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
|
||||
// in the chains where they were found.
|
||||
for name, chains := range found {
|
||||
for chain, where := range chains {
|
||||
if where != "prefix" {
|
||||
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)
|
||||
// The ones that stay rules, in their place: say which.
|
||||
for _, chain := range []string{model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput} {
|
||||
for _, h := range heads[chain] {
|
||||
if found[h.builtin][chain] == "demoted" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
im.positions[chain]++
|
||||
return im.positions[chain]
|
||||
|
||||
@@ -50,11 +50,24 @@ func TestImportRouter(t *testing.T) {
|
||||
if s := st.Set("masq_to_internet"); s == nil {
|
||||
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
|
||||
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)
|
||||
}
|
||||
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
|
||||
// established and invalid rules: invalid stays rules, in order.
|
||||
if b.DropInvalid {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -14,6 +14,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -209,6 +210,9 @@ type Table struct {
|
||||
// Tables are all the machine's tables ("inet main"), when the whole
|
||||
// ruleset was read.
|
||||
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
|
||||
@@ -306,6 +310,9 @@ func ParseTable(data []byte) (*Table, error) {
|
||||
continue
|
||||
}
|
||||
mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ")
|
||||
if strings.HasPrefix(mark, render.FallbackMark+"-") {
|
||||
t.Fallback = true
|
||||
}
|
||||
for _, e := range r.Expr {
|
||||
var c struct {
|
||||
Counter *Counter `json:"counter"`
|
||||
@@ -411,6 +418,35 @@ type Firewall struct {
|
||||
// restore is the quota usage saved before housecarld (or the machine)
|
||||
// stopped, put back by the next apply.
|
||||
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
|
||||
@@ -482,6 +518,12 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if f.BootFile == "" {
|
||||
return nil
|
||||
}
|
||||
rs := render.Render(st, f.options())
|
||||
tmp := f.BootFile + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
|
||||
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 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
|
||||
|
||||
@@ -2,8 +2,10 @@ package nft
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -257,3 +259,109 @@ func TestDeviceCountsInContainer(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,15 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
||||
}
|
||||
stats := router()
|
||||
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)
|
||||
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}}}})
|
||||
|
||||
@@ -381,6 +381,47 @@ func usedBy(st *model.State, kind string, o model.Object) string {
|
||||
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).
|
||||
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
kind, err := pathKind(r)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,12 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
for _, k := range model.Kinds {
|
||||
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
|
||||
} else if err != nil {
|
||||
out.ApplyError = err.Error()
|
||||
} else if t.Fallback {
|
||||
out.ApplyError = fallbackInForce
|
||||
} else {
|
||||
out.Applied = true
|
||||
}
|
||||
|
||||
@@ -94,10 +94,21 @@ func updateOp(st *model.State, op *model.Op) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
o, err := findObject(st, op.Kind, op.Ref)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,12 @@ import (
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/version"
|
||||
)
|
||||
|
||||
func (s *Server) routes() {
|
||||
m := s.mux
|
||||
p := api.Prefix
|
||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
|
||||
})
|
||||
m.HandleFunc("GET /healthz", s.healthz)
|
||||
// Authentication, users and tokens
|
||||
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
||||
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
|
||||
|
||||
@@ -90,6 +90,7 @@ type Server struct {
|
||||
srvs []*http.Server
|
||||
|
||||
probation probation
|
||||
health healthCache
|
||||
logs *logRing
|
||||
// unitLinks are where an enabled nftables.service shows (tests change
|
||||
// them).
|
||||
|
||||
@@ -40,6 +40,7 @@ type fakeFW struct {
|
||||
resets []string
|
||||
traced string
|
||||
traceLines []string
|
||||
fallback bool // the boot fallback is in force
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
f.mu.Lock()
|
||||
|
||||
@@ -223,8 +223,8 @@ func (c *checker) zones() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.st.Zones) > 0 && !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")
|
||||
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; 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("Antispoof IPv4", v4),
|
||||
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."),
|
||||
h("label"), manual,
|
||||
field("Statistics", perDevice)),
|
||||
|
||||
@@ -355,6 +355,53 @@ sleep 1
|
||||
run "the log shows it" 0 H log
|
||||
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
|
||||
|
||||
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 "forwarded again" 0 connect net $WAN4 8096
|
||||
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
|
||||
|
||||
|
||||
Reference in new issue
Block a user