Compare commits

...
Author SHA1 Message Date
abrown38andClaude Opus 5.5 1e6c085ef9 Release 0.1.1-rc.1
release / release (push) Successful in 1m26s
Housecarl 0.1.1's first release candidate. Since 0.1.0: an importer
that keeps what rulesets mean in the cases an audit found (chains on one
hook, jumps, repeated matches, forward accepts, raw rules' sets), zone
names given at import, timeout sets as dynamic sets, and multi-port
DNATs. make test (with nft 1.1.3) and make e2e (246 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:43:46 +01:00
abrown38andClaude Opus 5.5 47bae07d42 Import: keep what rulesets mean, name zones as asked
Fixes from an audit of the importer, each of which could change what an
imported ruleset does: two base chains on one hook imported their rules
twice (and hoisted the second's built-ins), now in priority order with
the stricter policy; raw rules from a jumped-to chain lost the jump's
condition, now put in front; a jump and a rule matching one field are
combined only when one's addresses lie within the other's; a second
match on a field replaced the first, and != on hop limit and TCP flags
read as ==, now kept raw; a forward accept without ct status dnat no
longer folds into the port forward, nor an interface-limited IPv6
accept into an open pinhole; raw rules' set references are renamed
(NAME_v4/_v6) or written out, so nftables takes them; DNATs with
matches a forward can't hold are left out with a note.

Sets with flags timeout become dynamic sets; a DNAT of several ports one
forward per port. Zone names: --zone IFACE=NAME (and on the Import
page), VLAN defines, more suffixes, and a note on where each name came
from. testdata/audit.nft covers the cases, checked with nft 1.1.3.

make test and make e2e (246 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:43:42 +01:00
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
46 changed files with 2162 additions and 203 deletions

No files matched your search

+73 -1
View File
@@ -6,6 +6,47 @@ All notable changes to Housecarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Added
- **Zone names at import:** `hc import --zone enp2s0=lan` (repeat;
interfaces given one name share a zone; Zone names on the Import
page), and a note listing each zone and where its name came from.
Defines holding a VLAN sub-interface (`enp2s0.20`) name their zone,
and `_ifname`, `_device` and `_nic` are taken off define names too
(docs/import.md, "How things are named").
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
several ports becomes one port forward per port.
### Fixed
- Import: two base chains on one hook had their rules imported twice,
and the second chain's leading built-ins were moved above the first
chain's rules; they now follow each other by priority (read in a
fixed order), with the stricter policy, and a note.
- Import: a rule inside a jumped-to chain that stays raw kept only its
own text and so applied to everything; the jump's condition now goes
in front. A jump and a rule matching the same field are combined only
when one's addresses lie within the other's; else the rule stays raw.
A jump's ports, ICMP types, connection status and hop limit were
dropped; a limit or log on the jump is no longer silently lost.
- Import: a second match on the same field (`ip saddr A ip saddr != B`)
replaced the first; `!=` on the hop limit and TCP flags was read as
`==`. Such rules now stay raw, as written.
- Import: a forward-chain accept to a forwarded host and port without
`ct status dnat` was folded into the port forward, which only lets
forwarded connections through; it stays a rule now. An IPv6 accept
limited to an interface no longer becomes a pinhole open to all.
- Import: raw rules naming a set of the old table made nftables refuse
the ruleset; address sets are now renamed `NAME_v4`/`NAME_v6`, other
sets written out with their elements, and what can't be is left out
with a note. Rules naming a set that isn't imported stay raw instead
of failing validation.
- Import: a DNAT to an IPv6 address, or with matches a port forward
can't hold (an interface excluded, a limit, a log), is left out with a
note instead of being imported without them.
## [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 +107,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.1 is in testing (0.1.1-rc.1): 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.1-rc.1
+17 -2
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
} }
} }
@@ -430,6 +434,7 @@ func rollbackCmd() *cobra.Command {
func importCmd() *cobra.Command { func importCmd() *cobra.Command {
var replace bool var replace bool
var table, defines, textFile string var table, defines, textFile string
var zones []string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "import [FILE|-]", Use: "import [FILE|-]",
Short: "Import an nftables ruleset (nft -j JSON) as Housecarl's configuration", Short: "Import an nftables ruleset (nft -j JSON) as Housecarl's configuration",
@@ -441,7 +446,9 @@ forwards and NAT. The input is nft's JSON; on the firewall:
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset' Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
itself (root on the firewall); from elsewhere, give the JSON as FILE and itself (root on the firewall); from elsewhere, give the JSON as FILE and
the text with --text. --defines reads the ruleset file's define lines, to the text with --text. --defines reads the ruleset file's define lines, to
name the sets as you did (admins, lan_net, …). -n shows the result without name the sets as you did (admins, lan_net, …), and the zones; --zone
enp2s0=lan names one yourself (repeat; interfaces given one name share a
zone). -n shows the result without
saving; what can't be expressed becomes raw rules, listed with notes. saving; what can't be expressed becomes raw rules, listed with notes.
Once it looks right, import without -n, check it with 'hc ruleset', and Once it looks right, import without -n, check it with 'hc ruleset', and
'hc takeover inet main' deletes the old table.`, 'hc takeover inet main' deletes the old table.`,
@@ -469,7 +476,14 @@ Once it looks right, import without -n, check it with 'hc ruleset', and
return err return err
} }
} }
in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace} in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace, Zones: map[string]string{}}
for _, z := range zones {
iface, name, ok := strings.Cut(z, "=")
if !ok || iface == "" || name == "" {
return fmt.Errorf("--zone %q: write it as INTERFACE=NAME (enp2s0=lan)", z)
}
in.Zones[iface] = name
}
if defines != "" { if defines != "" {
b, err := os.ReadFile(defines) b, err := os.ReadFile(defines)
if err != nil { if err != nil {
@@ -504,6 +518,7 @@ Once it looks right, import without -n, check it with 'hc ruleset', and
cmd.Flags().StringVar(&table, "table", "", "import only this table (\"inet main\"; default: all but Housecarl's)") cmd.Flags().StringVar(&table, "table", "", "import only this table (\"inet main\"; default: all but Housecarl's)")
cmd.Flags().StringVar(&defines, "defines", "", "the ruleset file, whose defines name the sets") cmd.Flags().StringVar(&defines, "defines", "", "the ruleset file, whose defines name the sets")
cmd.Flags().StringVar(&textFile, "text", "", "'nft -a list ruleset' output, to keep what can't be expressed as raw rules") cmd.Flags().StringVar(&textFile, "text", "", "'nft -a list ruleset' output, to keep what can't be expressed as raw rules")
cmd.Flags().StringArrayVar(&zones, "zone", nil, "name an interface's zone: INTERFACE=NAME (repeat)")
return cmd return cmd
} }
+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
+2 -2
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)) |
@@ -210,7 +210,7 @@ history, but are audited.
| `GET /history[?limit=&before=]` | changes, newest first | | `GET /history[?limit=&before=]` | changes, newest first |
| `GET /history/{id}` | one, with each object before and after | | `GET /history/{id}` | one, with each object before and after |
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already | | `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
| `POST /import/nft` | `{"json": NFT_JSON, "text": "nft -a list ruleset output", "defines": "ruleset file", "table": "inet main", "replace": false}` ([import.md](import.md)); answers with `state` (the imported configuration), `notes` and `raw` too | | `POST /import/nft` | `{"json": NFT_JSON, "text": "nft -a list ruleset output", "defines": "ruleset file", "table": "inet main", "zones": {"enp2s0": "lan"}, "replace": false}` ([import.md](import.md)); answers with `state` (the imported configuration), `notes` and `raw` too |
| `POST /takeover` | `{"tables": ["inet main"]}`: delete these tables (administrators); on probation over the network | | `POST /takeover` | `{"tables": ["inet main"]}`: delete these tables (administrators); on probation over the network |
### Certificate ### Certificate
+3 -3
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
@@ -219,7 +219,7 @@ See [nat.md](nat.md).
| `history list [--limit N]` | changes, newest first | | `history list [--limit N]` | changes, newest first |
| `history show ID` | each object before and after | | `history show ID` | each object before and after |
| `history undo ID` | apply the change backwards (refused if something it touched has changed since) | | `history undo ID` | apply the change backwards (refused if something it touched has changed since) |
| `import [FILE\|-] [--text FILE] [--defines FILE] [--table T] [--replace]` | [import.md](import.md) | | `import [FILE\|-] [--text FILE] [--defines FILE] [--table T] [--zone IFACE=NAME]... [--replace]` | [import.md](import.md) |
| `takeover TABLE... [-y]` | delete tables Housecarl's ruleset replaces (administrators) | | `takeover TABLE... [-y]` | delete tables Housecarl's ruleset replaces (administrators) |
### cert ### cert
+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.
+81 -17
View File
@@ -18,11 +18,9 @@ shows what it would make, without saving anything: zones, sets,
services, rules, port forwards and NAT, then **notes** on every guess it services, rules, port forwards and NAT, then **notes** on every guess it
made, and the rules it **kept raw**. made, and the rules it **kept raw**.
`--defines` reads your ruleset file's `define` lines, so sets get your `--defines` reads your ruleset file's `define` lines, so sets and zones
names (`define admins = { ... }` becomes the set `admins`). get your names ([How things are named](#how-things-are-named)).
Without it, sets are named after what they hold. Interfaces named by a `--zone enp2s0=lan` names a zone yourself.
define name their zone too (`define wan_interface = "ppp0"` makes the
zone `wan`); otherwise a zone is named after its interface.
From another machine, give the JSON as a file, and the text with From another machine, give the JSON as a file, and the text with
`--text`: `--text`:
@@ -37,6 +35,46 @@ With several tables, `--table "inet main"` picks one; by default every
table but Housecarl's is read. The web UI has the same under Server → table but Housecarl's is read. The web UI has the same under Server →
Import a ruleset. Import a ruleset.
## How things are named
**Zones** are named in this order:
1. **As you say**: `--zone enp2s0=lan` (repeat it; Zone names on the
web UI's Import page). Interfaces given the same name share one zone
(`--zone enp2s0=lan --zone enp3s0=lan`).
2. **After a define holding the interface**, with a trailing `_int`,
`_if`, `_iface`, `_ifname`, `_interface`, `_dev`, `_device` or `_nic`
taken off: `define wan_int = "ppp0"` makes the zone `wan`, `define
iot_if = enp2s0.20` (a VLAN) the zone `iot`.
3. **After the interface itself**: `enp2s0`.
If the name is taken, the zone is called `INTERFACE_zone`. A define
names what it was *for* in your old ruleset, which isn't always what the
network is (`define dhcp_int = enp2s0` makes `dhcp`, for your LAN). A
note lists each zone and where its name came from; rename one with
`--zone` and import again, or afterwards with `hc zone update dhcp
--name lan` (its rules follow). Name zones for what the network is for:
`lan`, `iot`, `guest`, `servers`, `vpn`, `wan`.
**Address sets**: a named set of the table keeps its name; a define
holding a list of addresses, or one prefix, gives its name to a set
(`define trusted = { 10.0.10.0/24, 10.1.1.0/24 }` makes `trusted`,
wherever those addresses appear together). A single address stays an
address. When two defines hold the same addresses, the first by name
wins.
**Services**: a define holding several ports names a service (`define
web_ports = { 80, 443 }` makes `web_ports`); one port stays a port
(`tcp/22`).
**Port forwards** are named after their comment, without a leading
"Port forward" (`comment "Port Forward Jellyfin"` makes `jellyfin`);
several ports in one DNAT become one forward each (`web-80`, `web-443`),
and a DNAT map's elements are named after the map (`forwards-443`).
Names that aren't valid are made so: other characters become `_` (`-`
in forward names), and a name must start with a letter.
## What becomes what ## What becomes what
* **Interfaces** become zones. Zones are marked external when the rules * **Interfaces** become zones. Zones are marked external when the rules
@@ -45,18 +83,30 @@ Import a ruleset.
* **Sets and defines** of addresses become address sets; port lists * **Sets and defines** of addresses become address sets; port lists
become services. become services.
* **Jumps and gotos** to your own chains are flattened: the jump's * **Jumps and gotos** to your own chains are flattened: the jump's
condition is added to each rule of the chain, in place. condition is added to each rule of the chain, in place. When the jump
and a rule both match sources (or destinations), the narrower is kept
if one lies within the other; otherwise, and for any rule there that
stays raw, the jump's condition is written in front of the raw rule's
text, so it still only applies where the jump did.
* **Rules** keep their order. A rule that is a built-in (loopback, * **Rules** keep their order. A rule that is a built-in (loopback,
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, one per port when a rule has several. The
become part of the port forward, and a masquerade for inside sources forward-chain accepts that let them through (`ct status dnat`) become
to the same host becomes its hairpin. An IPv6 accept to the same port part of the port forward; an accept to the same host and port without
becomes the forward's IPv6 pinhole when there is only one candidate. `ct status dnat` lets in more than forwarded connections, and stays a
rule. A masquerade for inside sources to the same host becomes its
hairpin. An IPv6 accept to the same port, from everywhere, becomes the
forward's IPv6 pinhole when there is only one candidate. A DNAT to an
IPv6 address, or with matches a port forward has no field for, is left
out with a note.
* **Maps are expanded**, one element at a time. A DNAT map * **Maps are expanded**, one element at a time. A DNAT map
(`dnat ip to tcp dport map @forwards`, named or written in the rule) (`dnat ip to tcp dport map @forwards`, named or written in the rule)
becomes one port forward per element; forwards from a named map are becomes one port forward per element; forwards from a named map are
@@ -75,13 +125,27 @@ Import a ruleset.
Byte rates and quotas are imported too; a quota imported resets only by Byte rates and quotas are imported too; a quota imported resets only by
hand until you give it a period. A rule with a quota or a byte rate that hand until you give it a period. A rule with a quota or a byte rate that
accepts stays a raw rule ([rules.md](rules.md#bandwidth-and-quotas)). accepts stays a raw rule ([rules.md](rules.md#bandwidth-and-quotas)).
* **Sets with `flags timeout`** (filled by a program or a rule) become
dynamic sets, without their entries of the moment: whatever fills them
must fill Housecarl's from then on, as a note says.
* **Policies** become the chains' policies. * **Policies** become the chains' policies.
* **Anything else** becomes a raw rule with its original text, in place. * **Anything else** becomes a raw rule with its original text, in place:
A raw rule naming a set of the old table needs the new set name: a match Housecarl has no field for, two matches on the same field
Housecarl's are `NAME_v4` and `NAME_v6`, and a note says so. (`ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24`), `!=` where Housecarl
only has `==`, a set of interface names. Its set references are made
to work in Housecarl's table: an imported address set becomes
`@NAME_v4` or `@NAME_v6`, any other set (interface names, ports) is
written out with its elements. A raw rule naming a set that can't be
(a meter, an empty set) is left out with a note.
* **Logging rules** log to Housecarl's log (`hc log`), not the kernel's.
Chains on hooks Housecarl doesn't manage (ingress, a bridge table) are Several base chains on one hook (`input` at priority -10 and at 0) are
left out, with a note. put one after the other, by priority, into Housecarl's one chain, with
the stricter policy; a note says to check it, since a packet had to get
past each, and an accept in the first no longer ends there. A prerouting
filter chain after connection tracking becomes the early chain, which
runs before it: a note says so. Chains on hooks Housecarl doesn't manage
(ingress, a bridge table) are left out, with a note.
## 2. Import ## 2. Import
+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
+3
View File
@@ -198,6 +198,9 @@ type ImportInput struct {
// Replace replaces Housecarl's configuration; without it, the import // Replace replaces Housecarl's configuration; without it, the import
// is refused when there is one. // is refused when there is one.
Replace bool `json:"replace,omitempty"` Replace bool `json:"replace,omitempty"`
// Zones names the zones by interface ({"enp2s0": "lan"}); interfaces
// given the same name share a zone.
Zones map[string]string `json:"zones,omitempty"`
} }
// ImportResult reports an import. // ImportResult reports an import.
+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()
+98
View File
@@ -0,0 +1,98 @@
package importer
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"housecarl/internal/render"
"housecarl/internal/validate"
)
// TestImportAudit: rulesets the importer must not change the meaning of
// (testdata/audit.nft, as nft 1.1.3 lists it).
func TestImportAudit(t *testing.T) {
js, _ := os.ReadFile("testdata/audit.json")
txt, _ := os.ReadFile("testdata/audit.txt")
defs, _ := os.ReadFile("testdata/audit.nft")
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs), Zones: map[string]string{"enp2s0.20": "iot", "eth9": "x"}})
if err != nil {
t.Fatal(err)
}
st := res.State
notes := strings.Join(res.Notes, "\n")
text := render.Render(st, render.Options{}).Text
has := func(what, s string) {
t.Helper()
if !strings.Contains(text, s) {
t.Errorf("%s: no %q in\n%s", what, s, text)
}
}
// Two chains on the input hook: their rules once each, in priority
// order; established stays a rule after the first chain's rule.
if n := strings.Count(text, "192.0.2.66"); n != 1 {
t.Errorf("the early input chain's rule %d times", n)
}
if strings.Index(text, "192.0.2.66") > strings.Index(text, "ct state established,related counter accept comment \"hc:r") ||
!strings.Contains(notes, "both hook input") {
t.Errorf("chains on one hook:\n%s", text)
}
// A second match on a field, and != on the hop limit: raw, as written.
has("two source matches", "ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24 tcp dport 22 drop")
has("hop limit", "ip6 hoplimit != 255 drop")
// A jump: a rule within it is the rule's own; one outside it, or raw,
// keeps the jump's match in front.
has("within the jump", `ip saddr 10.9.0.5 tcp dport 22 counter accept comment "hc:r`)
has("outside the jump", "ip saddr 10.9.0.0/24 ip saddr 10.8.0.1 tcp dport 23 accept")
has("raw under the jump", "ip saddr 10.9.0.0/24 meta mark 0x00000001 accept")
// Sets: a timeout set is dynamic; an interface set is written out in
// the raw rule that names it.
if s := st.Set("blocklist"); s == nil || !s.Dynamic || s.Timeout != 3600 {
t.Errorf("blocklist: %+v", s)
}
has("dynamic set", "ip saddr @blocklist_v4 counter drop")
has("interface set", `iifname { "enp2s0", "enp3s0" } accept`)
// An accept to a forwarded host that isn't only for forwarded
// connections stays a rule; a DNAT of two ports, two forwards.
has("not only forwarded", `ip daddr 10.0.10.37 tcp dport 443 counter accept comment "hc:r`)
if len(st.Forwards) != 2 || st.Forwards[0].Name != "web-80" || st.Forwards[1].Name != "web-443" {
t.Errorf("forwards %+v", st.Forwards)
}
// Zones: as given (a VLAN), from defines, and why.
if z := st.Zone("iot"); z == nil || z.Interfaces[0] != "enp2s0.20" {
t.Errorf("zone iot: %+v", st.Zones)
}
if st.Zone("lan") == nil || st.Zone("wan") == nil {
t.Errorf("zones %+v", st.Zones)
}
for _, want := range []string{"iot (enp2s0.20: as given)", "lan (enp2s0: the define lan_if)", "--zone eth9=x: no rule names"} {
if !strings.Contains(notes, want) {
t.Errorf("no note %q in\n%s", want, notes)
}
}
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
t.Errorf("problems: %+v", validate.Errors(ps))
}
// And nftables takes it.
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
if len(cmd) == 0 {
return
}
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s\n%s", err, out, text)
}
}
// TestValidIface: interface names a define may hold, VLANs too.
func TestValidIface(t *testing.T) {
for s, want := range map[string]bool{"enp2s0": true, "wan": true, "enp2s0.20": true, "br0.4094": true, "veth*": true,
"10.0.10.5": false, "example.com": false, "a.b.c": false, "enp2s0.20.30": false, "averyveryverylongname": false} {
if validIface(s) != want {
t.Errorf("validIface(%q) = %v", s, !want)
}
}
}
+530 -138
View File
@@ -20,6 +20,7 @@ import (
"strings" "strings"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/render"
) )
// Input is what to import. // Input is what to import.
@@ -34,6 +35,9 @@ type Input struct {
// Table is the table to import ("inet main"); empty: the only table // Table is the table to import ("inet main"); empty: the only table
// that isn't Housecarl's. // that isn't Housecarl's.
Table string Table string
// Zones names zones by interface ("enp2s0": "lan"), over the names
// the importer would choose; interfaces given one name share a zone.
Zones map[string]string
} }
// Result is the imported configuration and what to know about it. // Result is the imported configuration and what to know about it.
@@ -77,11 +81,13 @@ type nftRule struct {
} }
type nftSet struct { type nftSet struct {
Family string `json:"family"` Family string `json:"family"`
Table string `json:"table"` Table string `json:"table"`
Name string `json:"name"` Name string `json:"name"`
Type json.RawMessage `json:"type"` Type json.RawMessage `json:"type"`
Elem []json.RawMessage `json:"elem"` Flags []string `json:"flags"`
Timeout int `json:"timeout"`
Elem []json.RawMessage `json:"elem"`
} }
// nftMap is a named map: its elements are [key, value] pairs. // nftMap is a named map: its elements are [key, value] pairs.
@@ -121,6 +127,8 @@ type importer struct {
meters map[string]bool // sets used as meters meters map[string]bool // sets used as meters
noted map[string]bool noted map[string]bool
positions map[string]float64 positions map[string]float64
// zoneFrom says where each zone's name came from, for the note.
zoneFrom map[string]string
} }
func (im *importer) note(format string, args ...any) { func (im *importer) note(format string, args ...any) {
@@ -136,7 +144,7 @@ func Import(in Input) (*Result, error) {
return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err) return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err)
} }
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{}, maps: map[string]nftMap{}, im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{}, maps: map[string]nftMap{},
text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}} text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}, zoneFrom: map[string]string{}}
var tables []nftTable var tables []nftTable
for _, item := range doc.Nftables { for _, item := range doc.Nftables {
if raw, ok := item["table"]; ok { if raw, ok := item["table"]; ok {
@@ -207,6 +215,7 @@ func Import(in Input) (*Result, error) {
im.importSets() im.importSets()
im.run() im.run()
im.nameForwards() im.nameForwards()
im.noteZones()
im.st.Renumber() im.st.Renumber()
return &Result{State: im.st, Table: im.table.Family + " " + im.table.Name, Notes: im.notes, Raw: im.raw}, nil return &Result{State: im.st, Table: im.table.Family + " " + im.table.Name, Notes: im.notes, Raw: im.raw}, nil
} }
@@ -309,10 +318,13 @@ func key(vals []string) string {
return strings.Join(s, ",") return strings.Join(s, ",")
} }
var ifaceRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.@-]{0,14}\*?$`) var ifaceRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_@-]{0,14}\*?$`)
// vlanRe is a VLAN sub-interface: enp2s0.20.
var vlanRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_@-]*\.[0-9]{1,4}$`)
func validIface(s string) bool { func validIface(s string) bool {
return ifaceRe.MatchString(s) && !strings.ContainsAny(s, ".:") || s == "lo" return len(s) <= 15 && (ifaceRe.MatchString(s) || vlanRe.MatchString(s))
} }
// ---- sets // ---- sets
@@ -360,12 +372,22 @@ func (im *importer) importSets() {
for _, e := range s.Elem { for _, e := range s.Elem {
entries = append(entries, values(e)...) entries = append(entries, values(e)...)
} }
if existing := im.st.Set(n); existing != nil { name := setName(n)
if contains(s.Flags, "timeout") || contains(s.Flags, "dynamic") {
// Filled at run time (by a program, or a rule): a dynamic set.
// Its entries now are passing state, not configuration.
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Dynamic: true, Timeout: s.Timeout})
im.used[name] = true
im.note("set %s has entries that come and go (flags %s): it became a dynamic set, without its %d entries now; whatever fills it must now fill Housecarl's (hc set entries add %s ADDRESS, the API, or nft add element inet housecarl %s_v4 / %s_v6)",
n, strings.Join(s.Flags, ","), len(entries), name, name, name)
continue
}
if existing := im.st.Set(name); existing != nil {
existing.Entries = append(existing.Entries, entries...) existing.Entries = append(existing.Entries, entries...)
continue continue
} }
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: setName(n), Entries: entries}) im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Entries: entries})
im.used[n] = true im.used[name] = true
} }
} }
@@ -397,7 +419,10 @@ func (im *importer) namedSet(entries []string) string {
return name return name
} }
// zone returns the zone of an interface, making it on first use. // zone returns the zone of an interface, making it on first use. Its
// name: the one given for it (Input.Zones); else the name of a define
// holding the interface ("define lan_if = enp2s0": lan); else the
// interface's own.
func (im *importer) zone(iface string) string { func (im *importer) zone(iface string) string {
for _, z := range im.st.Zones { for _, z := range im.st.Zones {
for _, i := range z.Interfaces { for _, i := range z.Interfaces {
@@ -406,21 +431,78 @@ func (im *importer) zone(iface string) string {
} }
} }
} }
name := iface if given, ok := im.in.Zones[iface]; ok {
name := setName(given)
// Interfaces given one name share its zone.
if z := im.st.Zone(name); z != nil && im.zoneFrom[name] == "given" {
z.Interfaces = append(z.Interfaces, iface)
return name
}
if im.st.Zone(name) == nil {
return im.newZone(name, iface, "given")
}
im.note("--zone %s=%s: the zone %s is taken (by %s), so %s is named as below", iface, given, name, strings.Join(im.st.Zone(name).Interfaces, ", "), iface)
}
name, from := iface, "its interface's name"
if d, ok := im.ifNames[iface]; ok { if d, ok := im.ifNames[iface]; ok {
name = d name, from = d, "the define "+d
for _, suf := range []string{"_interface", "_iface", "_int", "_if", "_dev"} { for _, suf := range []string{"_interface", "_ifname", "_iface", "_device", "_int", "_nic", "_if", "_dev"} {
name = strings.TrimSuffix(name, suf) name = strings.TrimSuffix(name, suf)
} }
} }
name = setName(strings.TrimSuffix(name, "*")) name = setName(strings.TrimSuffix(name, "*"))
if im.st.Zone(name) != nil { if im.st.Zone(name) != nil {
name = setName(iface + "_zone") name, from = setName(iface+"_zone"), "its interface's name (the other was taken)"
} }
return im.newZone(name, iface, from)
}
// zone2 is the zone an interface is in, or "" (making none).
func (im *importer) zoneOfIface(iface string) string {
for _, z := range im.st.Zones {
if contains(z.Interfaces, iface) {
return z.Name
}
}
return ""
}
func (im *importer) newZone(name, iface, from string) string {
im.st.Zones = append(im.st.Zones, &model.Zone{ID: im.newID(), Name: name, Interfaces: []string{iface}}) im.st.Zones = append(im.st.Zones, &model.Zone{ID: im.newID(), Name: name, Interfaces: []string{iface}})
im.zoneFrom[name] = from
return name return name
} }
// noteZones says how each zone was named, and how to change it.
func (im *importer) noteZones() {
ifaces := make([]string, 0, len(im.in.Zones))
for i := range im.in.Zones {
ifaces = append(ifaces, i)
}
sort.Strings(ifaces)
for _, i := range ifaces {
if im.zoneOfIface(i) == "" {
im.note("--zone %s=%s: no rule names the interface %s, so there is no zone for it", i, im.in.Zones[i], i)
}
}
if len(im.st.Zones) == 0 {
return
}
var parts []string
for _, z := range im.st.Zones {
from := im.zoneFrom[z.Name]
if from == "given" {
from = "as given"
}
ext := ""
if z.External {
ext = ", external"
}
parts = append(parts, fmt.Sprintf("%s (%s%s: %s)", z.Name, strings.Join(z.Interfaces, ", "), ext, from))
}
im.note("zones: %s; name them yourself with --zone INTERFACE=NAME (lan, iot, guest: what the network is for), or later with hc zone update NAME --name NEW", strings.Join(parts, "; "))
}
func (im *importer) external(iface string) { func (im *importer) external(iface string) {
if z := im.st.Zone(im.zone(iface)); z != nil && !z.External { if z := im.st.Zone(im.zone(iface)); z != nil && !z.External {
z.External = true z.External = true
@@ -522,6 +604,11 @@ type parsed struct {
// name: a port forward's name, when the rule's comment won't do // name: a port forward's name, when the rule's comment won't do
// (one of a map's elements). // (one of a map's elements).
name string name string
// inJump: the rule came from a chain jumped to; jumpText is what the
// jumps to it match, as nftables text (none when it isn't known), put
// in front of the rule's own text if it stays raw.
inJump bool
jumpText string
} }
// mapUse is a lookup in a map: "dnat ip to tcp dport map @forwards" or // mapUse is a lookup in a map: "dnat ip to tcp dport map @forwards" or
@@ -550,7 +637,11 @@ func parse(r nftRule) *parsed {
return p return p
} }
func (p *parsed) bad(what string) { p.unsupported = append(p.unsupported, what) } func (p *parsed) bad(what string) {
if !contains(p.unsupported, what) {
p.unsupported = append(p.unsupported, what)
}
}
func (p *parsed) expr(k string, v json.RawMessage) { func (p *parsed) expr(k string, v json.RawMessage) {
switch k { switch k {
@@ -839,19 +930,24 @@ func (p *parsed) match(m match) {
if meta, ok := left["meta"]; ok { if meta, ok := left["meta"]; ok {
var x struct{ Key string } var x struct{ Key string }
json.Unmarshal(meta, &x) json.Unmarshal(meta, &x)
switch x.Key { switch {
case "iifname": case (x.Key == "iifname" || x.Key == "oifname") && anySet(right):
p.bad("meta " + x.Key + " in a named set")
case x.Key == "iifname" && len(p.inIf) > 0, x.Key == "oifname" && len(p.outIf) > 0:
// Both must hold; Housecarl's rule has one list: kept raw.
p.bad("two matches on meta " + x.Key)
case x.Key == "iifname":
p.inIf, p.notIn = right, not p.inIf, p.notIn = right, not
case "oifname": case x.Key == "oifname":
p.outIf, p.notOut = right, not p.outIf, p.notOut = right, not
case "nfproto": case x.Key == "nfproto":
if len(right) == 1 && !not { if len(right) == 1 && !not && (p.family == "" || p.family == right[0]) {
p.family = right[0] p.family = right[0]
} else { } else {
p.bad("meta nfproto") p.bad("meta nfproto")
} }
case "l4proto": case x.Key == "l4proto":
if len(right) == 1 && !not { if len(right) == 1 && !not && (p.proto == "" || p.proto == right[0]) {
p.proto = right[0] p.proto = right[0]
} else { } else {
p.bad("meta l4proto with several protocols") p.bad("meta l4proto with several protocols")
@@ -864,23 +960,29 @@ func (p *parsed) match(m match) {
if pl, ok := left["payload"]; ok { if pl, ok := left["payload"]; ok {
var x struct{ Protocol, Field string } var x struct{ Protocol, Field string }
json.Unmarshal(pl, &x) json.Unmarshal(pl, &x)
fam := map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
switch { switch {
// A second match on a field Housecarl keeps one list for (both
// must hold): the rule stays raw, as written.
case x.Field == "saddr" && len(p.src) > 0, x.Field == "daddr" && len(p.dst) > 0,
x.Field == "dport" && len(p.dports) > 0, x.Field == "type" && len(p.icmp) > 0:
p.bad("two matches on " + x.Protocol + " " + x.Field)
case fam != "" && p.family != "" && p.family != fam && (x.Field == "saddr" || x.Field == "daddr"):
p.bad("both IPv4 and IPv6 addresses")
case (x.Protocol == "ip" || x.Protocol == "ip6") && (x.Field == "saddr" || x.Field == "daddr"): case (x.Protocol == "ip" || x.Protocol == "ip6") && (x.Field == "saddr" || x.Field == "daddr"):
if p.family == "" { p.family = fam
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
}
if x.Field == "saddr" { if x.Field == "saddr" {
p.src, p.notSrc = right, not p.src, p.notSrc = right, not
} else { } else {
p.dst, p.notDst = right, not p.dst, p.notDst = right, not
} }
case (x.Protocol == "tcp" || x.Protocol == "udp" || x.Protocol == "sctp") && x.Field == "dport" && !not: case (x.Protocol == "tcp" || x.Protocol == "udp" || x.Protocol == "sctp") && x.Field == "dport" && !not && (p.proto == "" || p.proto == x.Protocol):
p.proto, p.dports = x.Protocol, right p.proto, p.dports = x.Protocol, right
case x.Protocol == "tcp" && x.Field == "flags" && len(right) == 1 && right[0] == "syn": case x.Protocol == "tcp" && x.Field == "flags" && len(right) == 1 && right[0] == "syn" && !not:
p.tcpSyn = true p.tcpSyn = true
case (x.Protocol == "icmp" || x.Protocol == "icmpv6") && x.Field == "type" && !not: case (x.Protocol == "icmp" || x.Protocol == "icmpv6") && x.Field == "type" && !not:
p.icmpProto, p.icmp = x.Protocol, right p.icmpProto, p.icmp = x.Protocol, right
case x.Protocol == "ip6" && x.Field == "hoplimit" && len(right) == 1 && right[0] == "255": case x.Protocol == "ip6" && x.Field == "hoplimit" && len(right) == 1 && right[0] == "255" && !not:
p.hop255 = true p.hop255 = true
default: default:
p.bad(x.Protocol + " " + x.Field) p.bad(x.Protocol + " " + x.Field)
@@ -891,9 +993,9 @@ func (p *parsed) match(m match) {
var x struct{ Key string } var x struct{ Key string }
json.Unmarshal(ct, &x) json.Unmarshal(ct, &x)
switch { switch {
case x.Key == "state" && !not: case x.Key == "state" && !not && len(p.ctState) == 0:
p.ctState = right p.ctState = right
case x.Key == "status" && !not: case x.Key == "status" && !not && len(p.ctStatus) == 0:
p.ctStatus = right p.ctStatus = right
default: default:
p.bad("ct " + x.Key) p.bad("ct " + x.Key)
@@ -905,43 +1007,85 @@ func (p *parsed) match(m match) {
} }
} }
// anySet reports whether a match's values name a set ("@name").
func anySet(vals []string) bool {
for _, v := range vals {
if strings.HasPrefix(v, "@") {
return true
}
}
return false
}
// ---- converting // ---- converting
func (im *importer) run() { func (im *importer) run() {
type base struct { type base struct {
name, kind string name, kind string
prio int
} }
var bases []base var bases []base
for name, c := range im.chains { names := make([]string, 0, len(im.chains))
for name := range im.chains {
names = append(names, name)
}
sort.Strings(names)
policies := map[string][]string{}
for _, name := range names {
c := im.chains[name]
switch { switch {
case c.Type == "filter" && c.Hook == "prerouting": case c.Type == "filter" && c.Hook == "prerouting":
bases = append(bases, base{name, model.ChainEarly}) bases = append(bases, base{name, model.ChainEarly, c.Prio})
if c.Prio > -200 {
im.note("chain %s hooks prerouting at priority %d, after connection tracking; Housecarl's early chain runs before it (raw), where connection states aren't known yet: check its rules", name, c.Prio)
}
case c.Type == "filter" && (c.Hook == "input" || c.Hook == "forward" || c.Hook == "output"): case c.Type == "filter" && (c.Hook == "input" || c.Hook == "forward" || c.Hook == "output"):
bases = append(bases, base{name, c.Hook}) bases = append(bases, base{name, c.Hook, c.Prio})
policies[c.Hook] = append(policies[c.Hook], c.Policy)
case c.Type == "nat" && c.Hook == "prerouting": case c.Type == "nat" && c.Hook == "prerouting":
bases = append(bases, base{name, "dstnat"}) bases = append(bases, base{name, "dstnat", c.Prio})
case c.Type == "nat" && c.Hook == "postrouting": case c.Type == "nat" && c.Hook == "postrouting":
bases = append(bases, base{name, "srcnat"}) bases = append(bases, base{name, "srcnat", c.Prio})
case c.Hook != "": case c.Hook != "":
im.note("chain %s (%s %s) isn't imported: Housecarl doesn't manage that hook", name, c.Type, c.Hook) im.note("chain %s (%s %s) isn't imported: Housecarl doesn't manage that hook", name, c.Type, c.Hook)
} }
set := &im.st.Settings }
switch { // Several chains on one hook: a packet must get past each (an accept
case c.Hook == "input" && c.Type == "filter": // ends only its own chain), and any policy drop drops it.
set.InputPolicy = c.Policy set := &im.st.Settings
case c.Hook == "forward" && c.Type == "filter": for hook, ps := range policies {
set.ForwardPolicy = c.Policy policy := model.PolicyAccept
case c.Hook == "output" && c.Type == "filter": if contains(ps, model.PolicyDrop) {
set.OutputPolicy = c.Policy policy = model.PolicyDrop
}
switch hook {
case "input":
set.InputPolicy = policy
case "forward":
set.ForwardPolicy = policy
case "output":
set.OutputPolicy = policy
} }
} }
// NAT first: the filter chains' port-forward accepts are folded into // NAT first: the filter chains' port-forward accepts are folded into
// the port forwards. // the port forwards. Chains on the same hook in priority order.
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 {
if bases[i].kind != bases[j].kind {
return order[bases[i].kind] < order[bases[j].kind]
}
return bases[i].prio < bases[j].prio
})
for i := 1; i < len(bases); i++ {
if b, a := bases[i], bases[i-1]; b.kind == a.kind && b.kind != "dstnat" && b.kind != "srcnat" {
im.note("chains %s and %s both hook %s: a packet must get past each, but Housecarl has one %s chain, so their rules follow each other (by priority); that only does the same if what %s accepts, %s would accept too: check it", a.name, b.name, b.kind, b.kind, a.name, b.name)
}
}
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 +1098,13 @@ func (im *importer) run() {
im.srcnat(p) im.srcnat(p)
} }
default: default:
prefix := true if !contains(chains, b.kind) {
chains = append(chains, b.kind)
}
// Built-ins come first only at the head of the hook's first
// chain (or of one after a chain of nothing but built-ins).
prefix := len(rest[b.kind]) == 0
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 +1115,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
@@ -1007,6 +1162,7 @@ func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
} }
jumpMatch := *p jumpMatch := *p
jumpMatch.verdict, jumpMatch.target = "", "" jumpMatch.verdict, jumpMatch.target = "", ""
jumpMatch.jumpText = joinText(p.jumpText, im.condText(p))
out = append(out, im.flatten(p.target, &jumpMatch, depth+1)...) out = append(out, im.flatten(p.target, &jumpMatch, depth+1)...)
if p.verdict == "goto" { if p.verdict == "goto" {
im.note("the goto to %s became the same rules followed by the rest of %s: check the order", p.target, chain) im.note("the goto to %s became the same rules followed by the rest of %s: check the order", p.target, chain)
@@ -1138,36 +1294,153 @@ func hasMatch(p, prefix *parsed) bool {
len(p.inIf) != len(prefix.inIf) || len(p.outIf) != len(prefix.outIf) || len(p.ctState) != len(prefix.ctState) || len(p.icmp) != len(prefix.icmp) len(p.inIf) != len(prefix.inIf) || len(p.outIf) != len(prefix.outIf) || len(p.ctState) != len(prefix.ctState) || len(p.icmp) != len(prefix.icmp)
} }
// merge puts a jump's matches in front of a jumped-to rule's. When both // condText is a jump rule's text without its verdict, counter and
// match the same field, the rule's own (narrower, usually) is kept and // comment: what it matches ("ip saddr 10.50.50.0/24"); "" when there is
// noted. // no text for it.
var verdictRe = regexp.MustCompile(`\s*\b(jump|goto)\s+\S+\s*$`)
func (im *importer) condText(p *parsed) string {
text, ok := im.text[p.handle]
if !ok {
return ""
}
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
text = text[:i]
}
text = verdictRe.ReplaceAllString(strings.TrimSpace(text), "")
return strings.Join(strings.Fields(strings.ReplaceAll(" "+text+" ", " counter ", " ")), " ")
}
func joinText(a, b string) string {
return strings.TrimSpace(a + " " + b)
}
// merge puts a jump's matches in front of a jumped-to rule's: the rule
// applies where both match. When both match the same field, that holds as
// one list only when one's values lie within the other's (the narrower is
// kept); otherwise the rule is kept raw, after the jump's text.
func (im *importer) merge(prefix, p *parsed) *parsed { func (im *importer) merge(prefix, p *parsed) *parsed {
q := *p q := *p
q.inJump, q.jumpText = true, prefix.jumpText
q.unsupported = append(append([]string{}, prefix.unsupported...), p.unsupported...) q.unsupported = append(append([]string{}, prefix.unsupported...), p.unsupported...)
fill := func(what string, dst *[]string, not *bool, src []string, srcNot bool) { both := func(what string) { q.bad("both the jump to it and the rule match " + what) }
if len(*dst) == 0 && len(src) > 0 { addrs := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
switch {
case len(src) == 0:
case len(*dst) == 0:
*dst, *not = src, srcNot *dst, *not = src, srcNot
} else if len(*dst) > 0 && len(src) > 0 && key(*dst) != key(src) { case *not || srcNot:
im.note("rule handle %d: both the jump to it and the rule match %s; the rule's own (%s) are kept, the jump's (%s) dropped: check it", if key(*dst) != key(src) || *not != srcNot {
p.handle, what, strings.Join(*dst, ", "), strings.Join(src, ", ")) both(what)
}
case im.within(*dst, src):
// The rule's own are narrower: they say it all.
case im.within(src, *dst):
*dst = src
default:
both(what)
} }
} }
fill("interfaces in", &q.inIf, &q.notIn, prefix.inIf, prefix.notIn) same := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
fill("interfaces out", &q.outIf, &q.notOut, prefix.outIf, prefix.notOut) switch {
fill("sources", &q.src, &q.notSrc, prefix.src, prefix.notSrc) case len(src) == 0:
fill("destinations", &q.dst, &q.notDst, prefix.dst, prefix.notDst) case len(*dst) == 0:
if q.family == "" { *dst, *not = src, srcNot
case key(*dst) != key(src) || *not != srcNot:
both(what)
}
}
var noNot bool
same("interfaces in", &q.inIf, &q.notIn, prefix.inIf, prefix.notIn)
same("interfaces out", &q.outIf, &q.notOut, prefix.outIf, prefix.notOut)
addrs("sources", &q.src, &q.notSrc, prefix.src, prefix.notSrc)
addrs("destinations", &q.dst, &q.notDst, prefix.dst, prefix.notDst)
same("connection states", &q.ctState, &noNot, prefix.ctState, false)
same("connection status", &q.ctStatus, &noNot, prefix.ctStatus, false)
switch {
case prefix.family == "":
case q.family == "":
q.family = prefix.family q.family = prefix.family
case q.family != prefix.family:
both("an address family")
} }
if len(q.ctState) == 0 { switch {
q.ctState = prefix.ctState case prefix.proto == "":
} case q.proto == "":
if q.proto == "" {
q.proto, q.dports = prefix.proto, prefix.dports q.proto, q.dports = prefix.proto, prefix.dports
case q.proto != prefix.proto:
both("a protocol")
default:
same("ports", &q.dports, &noNot, prefix.dports, false)
}
switch {
case prefix.icmpProto == "":
case q.icmpProto == "":
q.icmpProto, q.icmp = prefix.icmpProto, prefix.icmp
case q.icmpProto != prefix.icmpProto || key(q.icmp) != key(prefix.icmp):
both("ICMP types")
}
q.hop255 = q.hop255 || prefix.hop255
q.tcpSyn = q.tcpSyn || prefix.tcpSyn
if prefix.limit != nil || prefix.quota != nil || prefix.connLimit > 0 || prefix.log {
// Once in the jump, these count or log each packet once; on every
// rule they would act once per rule.
q.bad("a jump with a limit, quota or log")
} }
return &q return &q
} }
// within reports whether every address of inner lies within outer's
// (literal addresses and prefixes, or sets of the table).
func (im *importer) within(inner, outer []string) bool {
resolve := func(list []string) ([]netip.Prefix, bool) {
var out []netip.Prefix
for _, e := range list {
vals := []string{e}
if strings.HasPrefix(e, "@") {
s, ok := im.sets[e[1:]]
if !ok {
return nil, false
}
vals = nil
for _, el := range s.Elem {
vals = append(vals, values(el)...)
}
}
for _, v := range vals {
pf, err := netip.ParsePrefix(v)
if err != nil {
a, err := netip.ParseAddr(v)
if err != nil {
return nil, false // a range, or something else
}
pf = netip.PrefixFrom(a, a.BitLen())
}
out = append(out, pf.Masked())
}
}
return out, len(out) > 0
}
in, ok1 := resolve(inner)
out, ok2 := resolve(outer)
if !ok1 || !ok2 {
return false
}
for _, a := range in {
found := false
for _, b := range out {
if b.Bits() <= a.Bits() && b.Contains(a.Addr()) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// ---- built-ins // ---- built-ins
var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-big": true, "time-exceeded": true, "parameter-problem": true, "echo-request": true, "echo-reply": true} var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-big": true, "time-exceeded": true, "parameter-problem": true, "echo-request": true, "echo-reply": true}
@@ -1239,10 +1512,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 +1535,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 +1573,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]
@@ -1385,8 +1666,19 @@ func (im *importer) zones(ifaces []string) []string {
return out return out
} }
// checkSets marks a rule naming a set that wasn't imported (not of
// addresses, or a meter) unsupported: it stays raw.
func (im *importer) checkSets(p *parsed) {
for _, e := range append(append([]string{}, p.src...), p.dst...) {
if n, ok := strings.CutPrefix(e, "@"); ok && (im.meters[n] || im.st.Set(setName(n)) == nil) {
p.bad("the set @" + n + ", which isn't an address set Housecarl imported")
}
}
}
// toMatch builds a Match; ok is false when something can't be said. // toMatch builds a Match; ok is false when something can't be said.
func (im *importer) toMatch(p *parsed) (model.Match, bool) { func (im *importer) toMatch(p *parsed) (model.Match, bool) {
im.checkSets(p)
svcs, ok := im.services(p) svcs, ok := im.services(p)
if !ok || len(p.unsupported) > 0 { if !ok || len(p.unsupported) > 0 {
return model.Match{}, false return model.Match{}, false
@@ -1424,6 +1716,10 @@ func (im *importer) rule(chain string, p *parsed) {
default: default:
p.bad(p.verdict) p.bad(p.verdict)
} }
if p.log && !im.noted["log"] {
im.noted["log"] = true
im.note("rules that log now log to Housecarl's log (hc log, Traffic → Log), not the kernel log or the journal")
}
m, ok := im.toMatch(p) m, ok := im.toMatch(p)
if r.CountsTraffic() && r.Action == model.ActionAccept { if r.CountsTraffic() && r.Action == model.ActionAccept {
// Housecarl checks these before established connections are // Housecarl checks these before established connections are
@@ -1454,6 +1750,14 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
if i := strings.LastIndex(text, ` comment "`); i >= 0 { if i := strings.LastIndex(text, ` comment "`); i >= 0 {
text = strings.TrimSpace(text[:i]) text = strings.TrimSpace(text[:i])
} }
// From a chain jumped to: only where the jumps matched.
if p.inJump {
if p.jumpText == "" {
im.note("rule handle %d of chain %s couldn't be expressed (%s), and the jump to it has no text to keep it raw with: add it by hand: %s", p.handle, r.Chain, strings.Join(p.unsupported, ", "), text)
return
}
text = p.jumpText + " " + text
}
// A named map isn't carried over: a raw rule naming it would make // A named map isn't carried over: a raw rule naming it would make
// nftables refuse the whole ruleset. // nftables refuse the whole ruleset.
for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) { for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) {
@@ -1463,6 +1767,12 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
return return
} }
} }
text, bad := im.setRefs(text)
if bad != "" {
im.note("rule handle %d of chain %s uses the set @%s, which Housecarl's table doesn't have (%s): left out, add it by hand: %s",
p.handle, r.Chain, bad, strings.Join(p.unsupported, ", "), text)
return
}
if r.Action == "" { if r.Action == "" {
r.Action = model.ActionAccept r.Action = model.ActionAccept
} }
@@ -1474,41 +1784,86 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
why = "a match Housecarl has no field for" why = "a match Housecarl has no field for"
} }
im.raw = append(im.raw, fmt.Sprintf("%s: %s (%s)", r.Chain, text, why)) im.raw = append(im.raw, fmt.Sprintf("%s: %s (%s)", r.Chain, text, why))
if strings.Contains(text, "@") {
im.note("raw rule %q names a set of the old table: Housecarl's sets are NAME_v4 and NAME_v6", text)
}
} }
var addrSetRefRe = regexp.MustCompile(`\b(ip6?) (saddr|daddr) (!= )?@([A-Za-z0-9_]+)`)
// setRefs makes a raw rule's set references work in Housecarl's table:
// an imported address set is NAME_v4 or NAME_v6 there, by the family the
// rule matches; another set of the old table (interface names, ports) is
// written out as an anonymous set of its elements. It returns the name of
// a set it couldn't resolve (a meter, an empty or unknown one).
func (im *importer) setRefs(text string) (string, string) {
text = addrSetRefRe.ReplaceAllStringFunc(text, func(m string) string {
sm := addrSetRefRe.FindStringSubmatch(m)
name := setName(sm[4])
if im.meters[sm[4]] || im.st.Set(name) == nil {
return m
}
return fmt.Sprintf("%s %s %s@%s", sm[1], sm[2], sm[3], render.SetName(name, sm[1] == "ip6"))
})
bad := ""
text = mapRefRe.ReplaceAllStringFunc(text, func(m string) string {
n := m[1:]
if strings.HasSuffix(n, "_v4") || strings.HasSuffix(n, "_v6") {
if im.st.Set(strings.TrimSuffix(strings.TrimSuffix(n, "_v4"), "_v6")) != nil {
return m // rewritten above
}
}
set, ok := im.sets[n]
if !ok || im.meters[n] || len(set.Elem) == 0 || contains(set.Flags, "timeout") || contains(set.Flags, "dynamic") {
bad = n
return m
}
var typ string
json.Unmarshal(set.Type, &typ)
var elems []string
for _, e := range set.Elem {
for _, v := range values(e) {
if typ == "ifname" || typ == "string" {
v = quote(v)
}
elems = append(elems, v)
}
}
return "{ " + strings.Join(elems, ", ") + " }"
})
return text, bad
}
func quote(s string) string { return `"` + strings.ReplaceAll(s, `"`, "") + `"` }
// ---- NAT // ---- NAT
func (im *importer) dstnat(p *parsed) { func (im *importer) dstnat(p *parsed) {
im.checkSets(p)
if a, err := netip.ParseAddr(p.dnatAddr); err == nil && a.Is6() {
p.bad("a DNAT to an IPv6 address (Housecarl's port forwards translate IPv4; IPv6 gets a pinhole)")
}
if p.notIn || len(p.outIf) > 0 || p.limit != nil || p.quota != nil || p.connLimit > 0 || p.log || len(p.ctState) > 0 && key(p.ctState) != "new" || len(p.icmp) > 0 {
p.bad("a DNAT with matches a port forward has no field for")
}
switch { switch {
case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) == 1 && !p.notDst && !p.notSrc: case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) > 0 && !p.notDst && !p.notSrc:
ports, err := model.NormalizePorts(p.dports[0]) var ports []string
if err != nil { for _, d := range p.dports {
n, err := model.NormalizePorts(d)
if err != nil {
p.bad("the port " + d)
break
}
ports = append(ports, n)
}
if len(p.unsupported) > 0 {
break break
} }
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)} // "tcp dport { 80, 443 } dnat to X": a forward for each port.
if p.name != "" { for _, port := range ports {
f.Name = strings.ToLower(nameRe.ReplaceAllString(p.name, "-")) im.portForward(p, port, len(ports) > 1)
} }
if f.ToPort == ports {
f.ToPort = ""
}
f.PublicAddresses = im.addrRefs(p.dst)
f.Sources = im.addrRefs(p.src)
for _, i := range p.inIf { for _, i := range p.inIf {
im.external(i) im.external(i)
} }
// The same forward for another protocol: one forward, both.
for _, o := range im.st.Forwards {
if o.Ports == f.Ports && o.To == f.To && o.ToPort == f.ToPort && key(o.PublicAddresses) == key(f.PublicAddresses) && key(o.Sources) == key(f.Sources) {
o.Protocols = append(o.Protocols, p.proto)
return
}
}
f.ID, f.Position = im.newID(), float64(len(im.st.Forwards)+1)
im.st.Forwards = append(im.st.Forwards, f)
return return
case p.redirect != "" && p.redirect != "?" && len(p.unsupported) == 0: case p.redirect != "" && p.redirect != "?" && len(p.unsupported) == 0:
m, ok := im.toMatch(p) m, ok := im.toMatch(p)
@@ -1517,7 +1872,35 @@ func (im *importer) dstnat(p *parsed) {
return return
} }
} }
im.note("the dstnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle]) im.note("the dstnat rule handle %d couldn't be imported (%s): add it by hand: %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
}
// portForward makes the port forward for one public port of a DNAT rule
// (several: the port goes into its name).
func (im *importer) portForward(p *parsed, ports string, several bool) {
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)}
if p.name != "" {
f.Name = strings.ToLower(nameRe.ReplaceAllString(p.name, "-"))
}
if several && f.Name != "" {
f.Name += "-" + ports
}
if f.ToPort == ports {
f.ToPort = ""
}
f.PublicAddresses = im.addrRefs(p.dst)
f.Sources = im.addrRefs(p.src)
// The same forward for another protocol: one forward, both.
for _, o := range im.st.Forwards {
if o.Ports == f.Ports && o.To == f.To && o.ToPort == f.ToPort && key(o.PublicAddresses) == key(f.PublicAddresses) && key(o.Sources) == key(f.Sources) {
if !contains(o.Protocols, p.proto) {
o.Protocols = append(o.Protocols, p.proto)
}
return
}
}
f.ID, f.Position = im.newID(), float64(len(im.st.Forwards)+1)
im.st.Forwards = append(im.st.Forwards, f)
} }
var forwardWords = regexp.MustCompile(`(?i)^\s*port\s*forward(ing)?\s*[:-]?\s*`) var forwardWords = regexp.MustCompile(`(?i)^\s*port\s*forward(ing)?\s*[:-]?\s*`)
@@ -1553,6 +1936,7 @@ func (im *importer) nameForwards() {
} }
func (im *importer) srcnat(p *parsed) { func (im *importer) srcnat(p *parsed) {
im.checkSets(p)
if len(p.unsupported) > 0 { if len(p.unsupported) > 0 {
im.note("the srcnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle]) im.note("the srcnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
return return
@@ -1601,7 +1985,8 @@ func (im *importer) srcnat(p *parsed) {
// absorbForward folds a port forward's own forward-chain accept (and an // absorbForward folds a port forward's own forward-chain accept (and an
// IPv6 pinhole on its port) into the port forward. // IPv6 pinhole on its port) into the port forward.
func (im *importer) absorbForward(p *parsed) bool { func (im *importer) absorbForward(p *parsed) bool {
if p.verdict != "accept" || len(p.unsupported) > 0 || len(p.dst) != 1 || p.notDst || len(p.dports) != 1 || p.proto == "" || len(p.src) > 0 || len(p.outIf) > 0 { if p.verdict != "accept" || len(p.unsupported) > 0 || len(p.dst) != 1 || p.notDst || len(p.dports) != 1 || p.proto == "" || len(p.src) > 0 || len(p.outIf) > 0 ||
p.notIn || p.limit != nil || p.quota != nil || p.connLimit > 0 || p.log || len(p.ctState) > 0 && key(p.ctState) != "new" {
return false return false
} }
port, err := model.NormalizePorts(p.dports[0]) port, err := model.NormalizePorts(p.dports[0])
@@ -1610,6 +1995,12 @@ func (im *importer) absorbForward(p *parsed) bool {
} }
dst := p.dst[0] dst := p.dst[0]
if p.family == "ipv4" || !strings.Contains(dst, ":") { if p.family == "ipv4" || !strings.Contains(dst, ":") {
// The forward's own accept takes only what was port forwarded: an
// accept of anything to that host and port stays a rule (inside
// clients reach the host directly through it).
if key(p.ctStatus) != "dnat" {
return false
}
for _, f := range im.st.Forwards { for _, f := range im.st.Forwards {
inside := f.ToPort inside := f.ToPort
if inside == "" { if inside == "" {
@@ -1636,7 +2027,8 @@ func (im *importer) absorbForward(p *parsed) bool {
cands = append(cands, f) cands = append(cands, f)
} }
} }
if len(cands) == 1 && len(p.ctStatus) == 0 { // The pinhole lets in from everywhere: only an accept that did too.
if len(cands) == 1 && len(p.ctStatus) == 0 && len(p.inIf) == 0 && len(p.ctState) == 0 {
cands[0].To6 = dst cands[0].To6 = dst
im.note("the IPv6 rule to %s port %s became %s's IPv6 pinhole", dst, port, cands[0].Label()) im.note("the IPv6 rule to %s port %s became %s's IPv6 pinhole", dst, port, cands[0].Label())
return true return true
+25 -5
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 {
@@ -102,9 +115,16 @@ func TestImportRouter(t *testing.T) {
if !wgDNS { if !wgDNS {
t.Errorf("wg0_input not inlined") t.Errorf("wg0_input not inlined")
} }
// The jump's and the rule's sources both: the rule's are kept, noted. // The jump's and the rule's sources both: the rule's lie within the
if !strings.Contains(strings.Join(res.Notes, "\n"), "the rule's own (10.50.50.3, 10.50.50.6) are kept") { // jump's, so they say it all, without a note.
t.Errorf("no note on the overlapping sources: %q", res.Notes) var wgSSH bool
for _, r := range st.Rules {
if r.Chain == model.ChainInput && strings.Join(r.Services, ",") == "tcp/22" && strings.Join(r.Sources, ",") == "10.50.50.3,10.50.50.6" {
wgSSH = true
}
}
if !wgSSH || strings.Contains(strings.Join(res.Notes, "\n"), "jump") {
t.Errorf("the WireGuard SSH rule: found %v, notes %q", wgSSH, res.Notes)
} }
// The imported configuration is valid, and renders. // The imported configuration is valid, and renders.
if ps := validate.Check(st, nil); validate.HasErrors(ps) { if ps := validate.Check(st, nil); validate.HasErrors(ps) {
+1
View File
@@ -0,0 +1 @@
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "audit", "handle": 1}}, {"chain": {"family": "inet", "table": "audit", "name": "early", "handle": 1, "type": "filter", "hook": "input", "prio": -10, "policy": "accept"}}, {"chain": {"family": "inet", "table": "audit", "name": "input", "handle": 2, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "audit", "name": "admin", "handle": 3}}, {"chain": {"family": "inet", "table": "audit", "name": "forward", "handle": 4, "type": "filter", "hook": "forward", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "audit", "name": "prerouting", "handle": 5, "type": "nat", "hook": "prerouting", "prio": -100, "policy": "accept"}}, {"set": {"family": "inet", "name": "blocklist", "table": "audit", "type": "ipv4_addr", "handle": 6, "flags": ["timeout"], "timeout": 3600}}, {"set": {"family": "inet", "name": "lans", "table": "audit", "type": "ifname", "handle": 7, "elem": ["enp2s0", "enp3s0"]}}, {"rule": {"family": "inet", "table": "audit", "chain": "early", "handle": 8, "comment": "an early input chain", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "192.0.2.66"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 9, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 10, "comment": "dynamic", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "@blocklist"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 11, "comment": "an interface set", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "@lans"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 12, "comment": "two source matches", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "10.0.0.0", "len": 8}}}}, {"match": {"op": "!=", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "10.0.10.0", "len": 24}}}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 13, "comment": "hop limit not 255", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "icmpv6", "field": "type"}}, "right": "nd-neighbor-solicit"}}, {"match": {"op": "!=", "left": {"payload": {"protocol": "ip6", "field": "hoplimit"}}, "right": 255}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 14, "comment": "DNS from the IoT VLAN", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "enp2s0.20"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "udp", "field": "dport"}}, "right": 53}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "input", "handle": 15, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "10.9.0.0", "len": 24}}}}, {"jump": {"target": "admin"}}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "admin", "handle": 16, "comment": "within the jump", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "10.9.0.5"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "admin", "handle": 17, "comment": "outside the jump", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "10.8.0.1"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 23}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "admin", "handle": 18, "comment": "raw under the jump", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "mark"}}, "right": 1}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "forward", "handle": 19, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "forward", "handle": 20, "comment": "not only forwarded", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "daddr"}}, "right": "10.0.10.37"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 443}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "audit", "chain": "forward", "hanLine truncated
+53
View File
@@ -0,0 +1,53 @@
#! /sbin/nft -f
# Cases the importer must not get wrong (importer_test.go TestImportAudit).
define wan_if = "wan"
define lan_if = "enp2s0"
define iot_if = "enp2s0.20"
define admins = { 10.9.0.0/24 }
table inet audit {
set blocklist {
type ipv4_addr
flags timeout
timeout 1h
}
set lans {
type ifname
elements = { "enp2s0", "enp3s0" }
}
chain early {
type filter hook input priority -10; policy accept;
ip saddr 192.0.2.66 drop comment "an early input chain"
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ip saddr @blocklist drop comment "dynamic"
iifname @lans accept comment "an interface set"
ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24 tcp dport 22 drop comment "two source matches"
icmpv6 type nd-neighbor-solicit ip6 hoplimit != 255 drop comment "hop limit not 255"
iifname $iot_if udp dport 53 accept comment "DNS from the IoT VLAN"
ip saddr $admins jump admin
}
chain admin {
ip saddr 10.9.0.5 tcp dport 22 accept comment "within the jump"
ip saddr 10.8.0.1 tcp dport 23 accept comment "outside the jump"
meta mark 0x00000001 accept comment "raw under the jump"
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ip daddr 10.0.10.37 tcp dport 443 accept comment "not only forwarded"
iifname $lan_if oifname $wan_if accept
}
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
ip daddr 203.0.113.1 tcp dport { 80, 443 } dnat ip to 10.0.10.37 comment "web"
}
}
+46
View File
@@ -0,0 +1,46 @@
table inet audit { # handle 1
set blocklist { # handle 6
type ipv4_addr
timeout 1h
}
set lans { # handle 7
type ifname
elements = { "enp2s0",
"enp3s0" }
}
chain early { # handle 1
type filter hook input priority filter - 10; policy accept;
ip saddr 192.0.2.66 drop comment "an early input chain" # handle 8
}
chain input { # handle 2
type filter hook input priority filter; policy drop;
ct state established,related accept # handle 9
ip saddr @blocklist drop comment "dynamic" # handle 10
iifname @lans accept comment "an interface set" # handle 11
ip saddr 10.0.0.0/8 ip saddr != 10.0.10.0/24 tcp dport 22 drop comment "two source matches" # handle 12
icmpv6 type nd-neighbor-solicit ip6 hoplimit != 255 drop comment "hop limit not 255" # handle 13
iifname "enp2s0.20" udp dport 53 accept comment "DNS from the IoT VLAN" # handle 14
ip saddr 10.9.0.0/24 jump admin # handle 15
}
chain admin { # handle 3
ip saddr 10.9.0.5 tcp dport 22 accept comment "within the jump" # handle 16
ip saddr 10.8.0.1 tcp dport 23 accept comment "outside the jump" # handle 17
meta mark 0x00000001 accept comment "raw under the jump" # handle 18
}
chain forward { # handle 4
type filter hook forward priority filter; policy drop;
ct state established,related accept # handle 19
ip daddr 10.0.10.37 tcp dport 443 accept comment "not only forwarded" # handle 20
iifname "enp2s0" oifname "wan" accept # handle 21
}
chain prerouting { # handle 5
type nat hook prerouting priority dstnat; policy accept;
ip daddr 203.0.113.1 tcp dport { 80, 443 } dnat ip to 10.0.10.37 comment "web" # handle 23
}
}
+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)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
if err := decodeJSON(r, &in); err != nil { if err := decodeJSON(r, &in); err != nil {
return err return err
} }
res, err := importer.Import(importer.Input{JSON: in.JSON, Text: in.Text, Defines: in.Defines, Table: in.Table}) res, err := importer.Import(importer.Input{JSON: in.JSON, Text: in.Text, Defines: in.Defines, Table: in.Table, Zones: in.Zones})
if err != nil { if err != nil {
return badRequest("%v", err) return badRequest("%v", err)
} }
+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)
}
}
}
+10 -2
View File
@@ -8,14 +8,21 @@ export async function render(el) {
const text = textarea("", { rows: 4, class: "mono", placeholder: "optional, the output of: sudo nft -a list ruleset (keeps what Housecarl can't express as raw rules)" }); const text = textarea("", { rows: 4, class: "mono", placeholder: "optional, the output of: sudo nft -a list ruleset (keeps what Housecarl can't express as raw rules)" });
const defines = textarea("", { rows: 4, class: "mono", placeholder: "optional, your /etc/nftables.conf: its defines name the sets" }); const defines = textarea("", { rows: 4, class: "mono", placeholder: "optional, your /etc/nftables.conf: its defines name the sets" });
const table = input({ placeholder: "inet main (when there are several)", class: "mono" }); const table = input({ placeholder: "inet main (when there are several)", class: "mono" });
const zones = input({ placeholder: "enp2s0=lan, wg0=vpn (optional)", class: "mono" });
const replace = checkbox("replace Housecarl's configuration", false); const replace = checkbox("replace Housecarl's configuration", false);
const out = h("div"); const out = h("div");
const run = async (dry) => { const run = async (dry) => {
mount(out); mount(out);
const zoneNames = {};
for (const part of zones.value.split(",").map((s) => s.trim()).filter(Boolean)) {
const [iface, name] = part.split("=").map((s) => (s || "").trim());
if (!iface || !name) { mount(out, h("div", { class: "msg err" }, "Zone names: write each as interface=name (enp2s0=lan).")); return; }
zoneNames[iface] = name;
}
let parsed; let parsed;
try { parsed = JSON.parse(json.value); } catch { mount(out, h("div", { class: "msg err" }, "The ruleset isn't JSON: paste the output of nft -j list ruleset.")); return; } try { parsed = JSON.parse(json.value); } catch { mount(out, h("div", { class: "msg err" }, "The ruleset isn't JSON: paste the output of nft -j list ruleset.")); return; }
try { try {
const r = await api("POST", "/import/nft", { body: { json: parsed, text: text.value, defines: defines.value, table: table.value.trim(), replace: replace.input.checked }, const r = await api("POST", "/import/nft", { body: { json: parsed, text: text.value, defines: defines.value, table: table.value.trim(), zones: zoneNames, replace: replace.input.checked },
query: dry ? { dry_run: 1 } : undefined }); query: dry ? { dry_run: 1 } : undefined });
const st = r.state; const st = r.state;
mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") }, mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") },
@@ -34,7 +41,8 @@ export async function render(el) {
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Import a ruleset"), h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Import a ruleset"),
h("div", { class: "muted" }, "Turns an nftables ruleset into zones, sets, services, rules, port forwards and NAT. On the firewall, 'sudo hc import --defines /etc/nftables.conf -n' does the same from the command line."))), h("div", { class: "muted" }, "Turns an nftables ruleset into zones, sets, services, rules, port forwards and NAT. On the firewall, 'sudo hc import --defines /etc/nftables.conf -n' does the same from the command line."))),
h("div", { class: "panel" }, h("div", { class: "form-grid" }, h("div", { class: "panel" }, h("div", { class: "form-grid" },
field("Ruleset (JSON)", json), field("Ruleset (text)", text), field("Ruleset file", defines), field("Table", table), h("label"), replace), field("Ruleset (JSON)", json), field("Ruleset (text)", text), field("Ruleset file", defines), field("Table", table),
field("Zone names", zones, "Name zones by what the network is for; interfaces given one name share a zone. Otherwise a define holding the interface names it, or the interface's own name."), h("label"), replace),
h("div", { class: "actions form-actions" }, h("div", { class: "actions form-actions" },
h("button", { onclick: () => run(true) }, "Preview"), h("button", { onclick: () => run(true) }, "Preview"),
h("button", { class: "primary", onclick: () => run(false) }, "Import"))), h("button", { class: "primary", onclick: () => run(false) }, "Import"))),
+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)),
+84
View File
@@ -124,6 +124,10 @@ contains "several tables"
run "dry run of the import" 0 H import --table "inet main" --defines /w/router.nft -n run "dry run of the import" 0 H import --table "inet main" --defines /w/router.nft -n
contains "5 port forwards, 2 source NAT rules" contains "5 port forwards, 2 source NAT rules"
contains "dry run: nothing was changed" contains "dry run: nothing was changed"
contains "dhcp (enp2s0: the define dhcp_int)"
run "zone names given at import" 0 H import --table "inet main" --defines /w/router.nft --zone enp2s0=lan --zone wg0=vpn -n
contains "lan (enp2s0: as given)"
contains "vpn (wg0: as given)"
run "import" 0 H import --table "inet main" --defines /w/router.nft run "import" 0 H import --table "inet main" --defines /w/router.nft
contains "applied and saved" contains "applied and saved"
run "the ruleset is in force" 0 R nft list table inet housecarl run "the ruleset is in force" 0 R nft list table inet housecarl
@@ -355,6 +359,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 +420,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