From b059bb7b4b1902f1e1c7e53facaad83ef0bfb156 Mon Sep 17 00:00:00 2001 From: Garrett Allen <98648590+Gerrrt@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:41:31 +0000 Subject: [PATCH] fix(firewall): close the firewall's own addresses to every segment, key-only sshd Every interface's Block SSH/HTTPS/HTTP to pfSense rows named only that interface's address, so every segment reached the admin UI and sshd on the WAN address, and Hicks on the lab gateway too (2026-11-13 audit). ADR-0117 re-points them at This Firewall (self), adds a prometheus-only SSH pass on Winterfell, and sets SSHd Key Only. The UI change is the new runbook. check_firewall_claims.py now also asks which of the firewall's own addresses each segment reaches on ssh, http and https, evaluated the way pf does, with its first fixtures under self-tests.sh. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- ...ddresses-and-take-passwords-off-its-ssh.md | 116 +++ docs/changelog.md | 20 + docs/firewall-claims.yaml | 46 + docs/network.md | 27 +- .../close-the-firewall-to-its-segments.md | 214 +++++ docs/security.md | 12 + scripts/README.md | 2 +- scripts/check_firewall_claims.py | 843 ++++++++++++++++-- scripts/self-tests.sh | 4 + systemd/homelab-firewall-claims.service | 6 +- 11 files changed, 1214 insertions(+), 84 deletions(-) create mode 100644 docs/adr/0117-close-the-firewalls-own-addresses-and-take-passwords-off-its-ssh.md create mode 100644 docs/runbooks/close-the-firewall-to-its-segments.md diff --git a/README.md b/README.md index d8debbc5..d41ad33e 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,8 @@ documents for different readers. at all. Every pinned digest is also scanned weekly for fixable HIGH and CRITICAL CVEs, and each image with findings has an open issue until a scan finds it clean. -- **Documented decisions and runbooks.** 116 ADRs covering what was chosen - and what was rejected — including the costs accepted knowingly; 54 +- **Documented decisions and runbooks.** 117 ADRs covering what was chosen + and what was rejected — including the costs accepted knowingly; 55 runbooks for the operations that are easy to get wrong at 1am, one of which is the handover page a successor reads first. Every critical alert links to one. @@ -235,9 +235,9 @@ inbound passes may still reach it. Data flow and the maintained Mermaid topology │ ├── architecture.md network.md hardware.md │ ├── observability.md security.md roadmap.md changelog.md │ ├── diagrams/ # the network diagram (SVG) and its predecessors -│ ├── adr/ # 116 ADRs — architecture decision records +│ ├── adr/ # 117 ADRs — architecture decision records │ ├── exercises/ # purple-team exercise records — see its README -│ └── runbooks/ # 54 runbooks; successor-handover.md is the front door +│ └── runbooks/ # 55 runbooks; successor-handover.md is the front door └── Makefile # make help ``` diff --git a/docs/adr/0117-close-the-firewalls-own-addresses-and-take-passwords-off-its-ssh.md b/docs/adr/0117-close-the-firewalls-own-addresses-and-take-passwords-off-its-ssh.md new file mode 100644 index 00000000..bfe68a8f --- /dev/null +++ b/docs/adr/0117-close-the-firewalls-own-addresses-and-take-passwords-off-its-ssh.md @@ -0,0 +1,116 @@ +# ADR-0117: Close the firewall's own addresses to every segment, and take passwords off its SSH + +**Status:** Accepted · 2026-11 + +## Context + +A read-only audit of `morpheus` from `prometheus` on 2026-11-13 found two +things the documents said were closed. + +**Every segment reached the admin UI and `sshd` on the firewall's WAN +address.** Each interface carries *Block SSH to pfSense*, *Block HTTPS to +pfSense* and *Block HTTP to pfSense* (Winterfell carries only the last two). +Each block names that interface's own address and nothing else. The firewall +holds an address on every segment and one on the WAN, and the interface's +catch-all `pass ... to any` carried a packet to any of those not already +blocked. From `prometheus`, `https://` on the WAN address answered 200 and +port 22 there gave an OpenSSH banner. [`network.md`](../network.md) said +"Winterfell itself is blocked from it". That held for `10.0.99.1` and for no +other address. Hicks had a second way in: *Allow Hicks access to +ImaginationLAN* passes TCP to the whole of `10.0.30.0/24` +([ADR-0031](0031-narrow-hicks-to-a-named-list-on-winterfell-and-leave-the-lab-open.md)), +and the lab's gateway, `10.0.30.1`, is in it. + +`make check-firewall` could not see this, and that is a gap in its question, +not a defect in its code. It asks which *segments* a catch-all reaches, and the +WAN address is in no segment. + +**`sshd` accepted passwords, keyboard-interactive and root.** `auth.log`, read +on 2026-11-14, has `admin` logging in by password from a Hicks desktop on +2026-11-10, from the Hicks MacBook on 2026-11-13, and six times from `oracle` +on 2026-11-10. All of +Hicks reaches port 22 on the firewall by *Allow SSH to Winterfell*, and every +segment reached it on the WAN address. That includes the two corporate laptops +on Hicks, which [`network.md`](../network.md) says are to be treated as +untrusted. Two keys are authorised. One is `prometheus`'s, and it is how every +collector, the nightly export and `make check-firewall` log in, as `root`. + +## Decision + +**Every interface blocks ssh, http and https to `This Firewall (self)`, not to +its own address.** Each block's source is `any` and its family is IPv4+IPv6, +and the three rows sit above every other rule on the interface except the named +exceptions below. pfSense expands `(self)` to every address the firewall holds, +the WAN's included. The three rows keep their names. Hicks' three move up, above +*Allow Hicks access to ImaginationLAN*. + +**The exceptions are named passes above those blocks, and there are three:** + +| Segment | Pass | Why | +| --- | --- | --- | +| Hicks | *Allow SSH to Winterfell*, already there, 22 to `10.0.99.0/24` | administration from a workstation, key-only below | +| Hicks | *Allow HTTPS to pfSense on its management address*, already there, 443 to `10.0.99.1` | the admin UI | +| Winterfell | *Allow SSH to pfSense from prometheus*, new, `10.0.99.20` → `10.0.99.1:22` | the collectors, the export and the claims check | + +The switch LAN keeps pfSense's generated anti-lockout rule, which sits above +every user rule and passes 22, 80 and 443 to the LAN's own address. It is the +way back in if a rule change locks Hicks out, and the only thing on that +segment is `neo`. The rest of the switch LAN is closed by the same three blocks. + +So the admin UI answers at `10.0.99.1` to Hicks and nowhere else, and `sshd` +answers at `10.0.99.1` to Hicks and to `prometheus`. `oracle` and `trinity` lose +port 22 on the firewall. Nothing scheduled on either uses it. The 2026-11-10 +logins from `oracle` were a person at a shell there, and that person now uses +`prometheus` or Hicks. + +**`sshd` is key-only.** *System → Advanced → Admin Access → SSHd Key Only* is +set to *Public Key Only*, which turns off password and keyboard-interactive +authentication. Root login stays allowed, because `prometheus`'s jobs log in as +`root` (`FW_USER`'s default since `backup-firewall.sh`), and with passwords off +that is a key login too. Every machine that administers the firewall over SSH +has its key on `admin` under *System → User Manager* before the switch. The +password path that remains is the console, at the rack. + +**The claims file asks the second question, and the timer answers it nightly.** +`docs/firewall-claims.yaml` gains a `firewall` entry per interface: which of the +firewall's own addresses a host on that segment reaches on ssh, http and https. +`check_firewall_claims.py` derives it by evaluating the ruleset the way pf +does, one firewall address at a time, first `quick` match wins. It names each +address by the segment it sits on, or `WAN`, or `link-local`, and never prints +the number. A host-scoped pass like `prometheus`'s is not counted, because the +question is what the segment reaches. The fixtures run in CI under +`scripts/self-tests.sh`. The live run stays on `prometheus`, for +[ADR-0026](0026-check-the-documents-where-the-truth-is.md)'s reason. + +## Alternatives considered + +- **Bind the admin UI and `sshd` to `10.0.99.1` only.** pfSense offers no + listen address for either in its UI, and a change made under it is lost to the + next upgrade or restore. Rules are the lever pfSense supports. +- **One floating rule set instead of rows per interface.** Floating rules are + evaluated before every interface's own, so Hicks' two passes would have to + move there too. [`network.md`](../network.md) lists what each segment reaches + interface by interface, and so does `pfctl`. Three rows per interface keeps + those two agreeing. +- **Disable the anti-lockout rule.** That removes the one recovery path that + needs no console. Its cost is that a device on an untagged switch port reaches + the UI's login page, and it has been accepted since the switch LAN was closed + ([ADR-0025](0025-close-the-switch-lan-to-winterfell.md)). +- **Narrow Hicks' SSH to named workstations.** Hicks is a `/24` with no + per-host source by decision + ([ADR-0031](0031-narrow-hicks-to-a-named-list-on-winterfell-and-leave-the-lab-open.md)). + Key-only `sshd` is what stops a corporate laptop logging in. A per-host list + can follow when Hicks gets one. + +## Consequences + +- A rule that names one firewall address where it meant all of them now fails + the nightly check, by name, rather than waiting for the next audit. +- An `ssh admin@10.0.99.1` with no key on the MacBook, the desktops or `oracle` + fails after the switch. [`close-the-firewall-to-its-segments.md`](../runbooks/close-the-firewall-to-its-segments.md) + §1 proves every key that is meant to work before the setting changes. +- A mistake in the Hicks rows can lock the UI out from Hicks. Recovery is the + console's *Restore recent configuration*, or a cable into an untagged port on + `neo` and the anti-lockout rule. +- The GUI's default self-signed certificate, the undeclared Hicks pass into + `smaug` and the other audit findings are not decided here. diff --git a/docs/changelog.md b/docs/changelog.md index 431aa2a0..6348fdc9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -17,6 +17,26 @@ roadmap as it read that day, and the *Done* entries keep the shape they had there. `check_docs.py` does not check this file, for the reason its module docstring gives: it is a record, not a claim about now. +## 2026-11-14 + +- **The firewall's own addresses are closed to every segment, and its `sshd` + takes keys only + ([ADR-0117](adr/0117-close-the-firewalls-own-addresses-and-take-passwords-off-its-ssh.md)).** + A read-only audit of `morpheus` on 2026-11-13 found every segment reaching + the admin UI and `sshd` on the WAN address: each interface's *Block … to + pfSense* rows named only that interface's own address. From `prometheus`, + https there answered 200 and port 22 gave a banner, though `network.md` said + Winterfell was blocked from the UI. Hicks also reached both on the lab's + gateway, through *Allow Hicks access to ImaginationLAN*. `sshd` accepted + passwords; `admin` had logged in by password from Hicks and from `oracle` + that week. The blocks now name *This Firewall (self)*, Winterfell gains + *Allow SSH to pfSense from prometheus*, and *SSHd Key Only* is *Public Key + Only*, by + [`close-the-firewall-to-its-segments.md`](runbooks/close-the-firewall-to-its-segments.md). + `make check-firewall` was blind to it, because the WAN address is in no + segment. It now also asks which of the firewall's addresses each segment + reaches on ssh, http and https, with fixtures in CI. + ## 2026-11-13 - **An audit of `golem`, read-only, found its last verify failed and a diff --git a/docs/firewall-claims.yaml b/docs/firewall-claims.yaml index 890cd86a..4eb5235a 100644 --- a/docs/firewall-claims.yaml +++ b/docs/firewall-claims.yaml @@ -12,6 +12,15 @@ # other segment is blocked above the catch-all, or there is no catch-all for that # family at all. Those are different rulesets and the same posture. # +# WHAT `firewall` MEANS (ADR-0117). For each interface, which of the firewall's +# OWN addresses a host on that segment reaches on ssh, http and https. Each +# address is named by the segment it sits on, `WAN`, or `link-local`; never by +# number. The 2026-11-13 audit found every segment reaching the admin UI and +# sshd on the WAN address under rules named *Block HTTPS to pfSense*, and +# `wholesale` could not see it, because the WAN address is in no segment. +# A host-scoped pass (Winterfell's `prometheus` to 10.0.99.1:22) is not the +# segment's and is not listed here; docs/network.md names it. +# # It is deliberately NOT a list of what each segment can reach. The narrow # host-and-port exceptions — SNMP to the iLO, SSH to the firewall, the passes # from Hicks into management — are enumerated in docs/network.md, they change far @@ -48,6 +57,13 @@ interfaces: wholesale: inet: [] inet6: [OPT1, OPT2, OPT3, OPT4, OPT5, OPT6] + # The anti-lockout rule pfSense generates on LAN sits above every user rule + # and passes 22, 80 and 443 to this interface's own address, which is also + # its link-local. Kept as the way back in (ADR-0117). + firewall: + ssh: [Switch LAN, link-local] + http: [Switch LAN, link-local] + https: [Switch LAN, link-local] # Management. The one segment whose catch-all reaches nothing in either family. igc0.99: @@ -55,6 +71,12 @@ interfaces: wholesale: inet: [] inet6: [] + # `prometheus` alone reaches 10.0.99.1:22, by a host-scoped pass above the + # blocks; the segment reaches nothing. + firewall: + ssh: [] + http: [] + https: [] # Trusted workstations, and the one IPv4 exception in the estate: nothing # blocks Hicks from ImaginationLAN, so the catch-all grants that segment @@ -71,6 +93,14 @@ interfaces: wholesale: inet: [OPT4] inet6: [] + # The admin UI on 10.0.99.1:443, and ssh by *Allow SSH to Winterfell*, both + # above the blocks to (self). Not the lab's gateway, though *Allow Hicks + # access to ImaginationLAN* passes TCP to 10.0.30.0/24: the blocks sit + # above it. + firewall: + ssh: [Winterfell] + http: [] + https: [Winterfell] # Media, lab, IoT and guest. Identical posture, and the inet6 row is identical # for a reason worth stating: each of these carries paired inet/inet6 blocks @@ -84,21 +114,37 @@ interfaces: wholesale: inet: [] inet6: [LAN] + firewall: + ssh: [] + http: [] + https: [] igc0.30: segment: ImaginationLAN wholesale: inet: [] inet6: [LAN] + firewall: + ssh: [] + http: [] + https: [] igc0.20: segment: Skids wholesale: inet: [] inet6: [LAN] + firewall: + ssh: [] + http: [] + https: [] igc0.10: segment: Degens wholesale: inet: [] inet6: [LAN] + firewall: + ssh: [] + http: [] + https: [] diff --git a/docs/network.md b/docs/network.md index 17989c9d..640675ac 100644 --- a/docs/network.md +++ b/docs/network.md @@ -216,9 +216,21 @@ listed under [Hicks](#hicks--vlan-50--trusted), and nothing else. from `10.0.99.40` on the WAN graphs is that and not something to chase. It leaves by Winterfell's existing egress rule, and its fetcher refuses every private address, this segment's included ([ADR-0057](adr/0057-add-miniflux-to-the-sensitive-tier-with-its-fetcher-kept-off-winterfell.md)). -- pfSense's admin UI is reachable on this interface from Hicks only, by a - named pass to `10.0.99.1:443`. Winterfell itself is blocked from it: the 99 - interface drops HTTP and HTTPS to `10.0.99.1` above its egress rule. +- **The firewall answers on its own addresses to two places, since + 2026-11-14:** its admin UI to Hicks, at `10.0.99.1:443`, and `sshd` to Hicks + and `prometheus`, at `10.0.99.1:22`. Every interface blocks ssh, http and + https to *This Firewall (self)*, every address the firewall holds, the WAN's + included. The blocks sit above everything but three named passes: Hicks' + *Allow HTTPS to pfSense on its management address* and *Allow SSH to + Winterfell*, and Winterfell's *Allow SSH to pfSense from prometheus*, + `10.0.99.20 → 10.0.99.1:22`, for the collectors, the nightly export and + `make check-firewall`. Nothing else on Winterfell reaches either. Until then + each interface's blocks named only its own address, so every segment + reached both on the WAN address; the 2026-11-13 audit proved it from + `prometheus`. `sshd` takes keys only. + [ADR-0117](adr/0117-close-the-firewalls-own-addresses-and-take-passwords-off-its-ssh.md) + records the decision, and `docs/firewall-claims.yaml`'s `firewall` entries + are what `make check-firewall` holds the ruleset to. - **One pass into Skids, in force since 2026-09-28:** `10.0.99.40 → 10.0.20.20` on `80,443/tcp` — `trinity`'s Home Assistant to `bifrost`, the Hue bridge, and nothing else on 20 — **above** *Block access @@ -336,6 +348,15 @@ though not the only one — ImaginationLAN has two host-scoped passes to | `10.0.99.30` — `oracle` | `443/tcp` the wiki, served by its Caddy since [#847](https://github.com/Gerrrt/HomeLab/issues/847)'s cutover on 2026-10-07, and `80/tcp`, which only redirects to it ([`stacks/wiki/README.md`](../stacks/wiki/README.md#tls)). 443 had been dropped on 2026-09-30, when nothing listened on it ([#251](https://github.com/Gerrrt/HomeLab/issues/251)) | | `10.0.99.40` — `trinity` | `443/tcp` the sensitive tier, since 2026-09-28 | + **The firewall itself answers Hicks at `10.0.99.1` and nowhere else.** The + three *Block … to pfSense* rows name *This Firewall (self)* and sit directly + under the `22` and admin-UI passes, above *Allow Hicks access to + ImaginationLAN*. That rule passes TCP to all of `10.0.30.0/24`, the lab's + gateway included, and until 2026-11-14 it was a second way to the UI and + `sshd` ([ADR-0117](adr/0117-close-the-firewalls-own-addresses-and-take-passwords-off-its-ssh.md)). + `sshd` takes keys only, so the `22` pass gets a corporate laptop a prompt it + cannot answer. + **The source is the segment, not named hosts.** Every one of those passes is `vlan50 → …`, so any device on Hicks may use any of them. This note used to say the opposite — "only specific hosts, and only on management ports" — and diff --git a/docs/runbooks/close-the-firewall-to-its-segments.md b/docs/runbooks/close-the-firewall-to-its-segments.md new file mode 100644 index 00000000..77e68a10 --- /dev/null +++ b/docs/runbooks/close-the-firewall-to-its-segments.md @@ -0,0 +1,214 @@ +# Runbook: Close the firewall to its own segments + +**Target:** `morpheus`, the firewall (pfSense CE 2.9.0) +**Time:** about 40 minutes, most of it the rule edits in §2 to §5 +**You will need:** the pfSense web UI from a Hicks workstation, a shell on +`prometheus`, and the console at the rack in case a step locks the UI out + +[ADR-0117](../adr/0117-close-the-firewalls-own-addresses-and-take-passwords-off-its-ssh.md) +decides this. Every interface's *Block SSH/HTTPS/HTTP to pfSense* rows named +only that interface's address, so every segment reached the admin UI and `sshd` +on the firewall's WAN address, and Hicks reached them on the lab's gateway as +well. This makes the blocks name `This Firewall (self)`, adds one pass for +`prometheus`, and then turns off password login to `sshd`. + +> [!CAUTION] +> **Do §1 before anything else, and do §6 last.** §6 turns off SSH passwords. +> A machine whose key is not on `admin` before then can only get back in +> through the web UI or the console. + +**The way back if the UI stops answering from Hicks** is the console at the +rack: **15) Restore recent configuration**, and pick the configuration from +before your edit. The second way back is a laptop cabled into an untagged port +on `neo`, where pfSense's anti-lockout rule still passes the UI on `10.7.7.1`. + +--- + +## 1. Put every administering key on `admin`, and prove each one + +`sshd` already accepts keys. Two are authorised today: `prometheus`'s, and one +other. + +1. On each machine you administer the firewall from over SSH (the Hicks + MacBook and desktop, at least), print the key's fingerprint: + + ```bash + ssh-keygen -lf ~/.ssh/id_ed25519.pub + ``` + +2. Compare against what the firewall holds: + + ```bash + ssh root@10.0.99.1 'ssh-keygen -lf /root/.ssh/authorized_keys' | awk '{print $2}' + ``` + +3. For each machine whose fingerprint is missing, paste its **public** key + (the `.pub` file, never the private one) under **System → User Manager → + admin → Authorized SSH Keys**, on its own line, then **Save**. + +4. From each of those machines, prove the key works with passwords out of + the way: + + ```bash + ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no admin@10.0.99.1 true && echo key-ok + ``` + +**You should see** `key-ok` from every machine. Do not go on to §6 until +you do. + +## 2. Winterfell (OPT1) + +**Firewall → Rules → Winterfell.** + +### 2.1 Add the pass for `prometheus` + +| Setting | Value | +| --- | --- | +| Action | Pass | +| Interface | Winterfell | +| Address family | IPv4 | +| Protocol | TCP | +| Source | Address or alias, `10.0.99.20` | +| Destination | Address or alias, `10.0.99.1` | +| Destination port range | SSH (22) to SSH (22) | +| Description | `Allow SSH to pfSense from prometheus` | + +### 2.2 Re-point the two blocks, and add the third + +Edit *Block HTTPS to pfSense* and *Block HTTP to pfSense*, then add *Block SSH +to pfSense* (copy one of them and change the port). + +| Setting | Value | +| --- | --- | +| Action | Block | +| Address family | IPv4+IPv6 | +| Protocol | TCP | +| Source | Any | +| Destination | **This Firewall (self)** | +| Destination port range | SSH (22), HTTPS (443), HTTP (80): one per row | +| Description | `Block SSH to pfSense`, `Block HTTPS to pfSense`, `Block HTTP to pfSense` | + +### 2.3 Order + +Drag them to the top of the interface so it opens: + +1. `Allow SSH to pfSense from prometheus` +2. `Block SSH to pfSense` +3. `Block HTTPS to pfSense` +4. `Block HTTP to pfSense` + +Everything else follows in the order it already had. **Apply Changes.** + +Then, from `prometheus`, prove its key still gets in before going further: + +```bash +ssh -o BatchMode=yes root@10.0.99.1 true && echo prometheus-ok +``` + +If that fails, the pass is wrong or below the block. Fix it from the UI on +Hicks, which nothing on Winterfell's tab has touched. + +## 3. Hicks (OPT2) + +**Firewall → Rules → Hicks.** This tab is the one that can lock you out. The +UI you are using arrives by *Allow HTTPS to pfSense on its management address*, +and that pass must stay **above** the blocks. + +Edit the three existing blocks, *Block SSH to pfSense*, *Block HTTPS to +pfSense* and *Block HTTP to pfSense*, the same way as §2.2: source **Any**, +destination **This Firewall (self)**, family **IPv4+IPv6**. Then drag the three +up so the top of the tab reads: + +1. `Allow SSH from Mac to WSL` (where it already is; it never reaches the + firewall) +2. `Allow SSH to Winterfell` +3. `Allow HTTPS to pfSense on its management address` +4. `Block SSH to pfSense` +5. `Block HTTPS to pfSense` +6. `Block HTTP to pfSense` +7. `Allow DNS to the resolver`, and the rest in their existing order + +What matters is that the blocks sit above *Allow Hicks access to +ImaginationLAN*, which passes TCP to the lab's gateway, `10.0.30.1`. **Apply +Changes**, then reload the UI to prove the UI still answers. + +## 4. CasaBonita, ImaginationLAN, Skids and Degens + +On each of these four tabs, edit the three existing blocks as in §2.2: source +**Any**, destination **This Firewall (self)**, family **IPv4+IPv6**. They +already sit above every pass that could reach the firewall, so leave the order +as it is. + +On Degens the three are named *Block SSH*, *Block HTTP* and *Block HTTPS*. +Rename them to match the other tabs while you are there. + +On ImaginationLAN, source **Any** rather than *ImaginationLAN net* also +covers the WireGuard peers (`Tunnel_Peers`), which arrive on that interface from +another network. **Apply Changes** once, after the last tab. + +## 5. The switch LAN + +**Firewall → Rules → LAN.** Add the three blocks from §2.2 with interface +**LAN**, and drag them to the top of the user rules. pfSense's generated +*anti-lockout rule* still sits above them and keeps `10.7.7.1` reachable; that +is the second way back and it is meant to stay. **Apply Changes.** + +## 6. Turn off SSH passwords + +Only once every machine in §1 printed `key-ok`. + +**System → Advanced → Admin Access → Secure Shell:** set **SSHd Key Only** to +**Public Key Only**, then **Save**. Leave *Allow Agent Forwarding* off. Root +login stays as it is: `prometheus`'s jobs log in as `root`, by key. + +## 7. Verify + +From `prometheus`, in a checkout of this branch or of `main` after it merges: + +```bash +make check-firewall +``` + +**You should see** every line `PASS`, including the three `firewall` lines per +interface. Winterfell, CasaBonita, ImaginationLAN, Skids and Degens reach none +of the firewall's addresses. Hicks reaches `Winterfell` on ssh and https. The +switch LAN reaches its own address and its link-local, through the anti-lockout +rule. A `FAIL` names the interface, the port and the address by segment. Fix +the rule it points at rather than the claims file. + +Then prove it with packets, from `prometheus`. The WAN address is read off the +firewall and never typed or printed: + +```bash +wan="$(ssh root@10.0.99.1 'ifconfig em0 inet' | awk '/inet /{print $2; exit}')" +curl -sk -m 5 -o /dev/null -w 'https %{http_code}\n' "https://${wan}/" +timeout 5 bash -c "\s+to\s+<(\w+)__NETWORK>\s*$" ) +# One pf address, as pfctl prints it: any, a table, an interface in parens, a +# brace list, or an address with an optional prefix — each optionally negated. +ADDR = r"(?:!\s*)?(?:any|no-route|self|\{[^}]*\}|<[^>]+>|\([^)]+\)|[0-9A-Fa-f:.]+(?:/\d+)?)" +PORT = r"(?:[!<>]=?\s*\S+|=\s*\S+|\{[^}]*\}|\S+\s*(?:<>|><)\s*\S+|\S+)" +RULE = re.compile( + r"^(?Ppass|block)\b(?:\s+(?:drop|return-rst(?:\([^)]*\))?|return-icmp6?(?:\([^)]*\))?|return))?" + r"(?:\s+(?Pin|out))?" + r"(?:\s+log(?:\s*\([^)]*\))?)?" + r"(?P\s+quick)?" + r"(?:\s+on\s+(?P!\s*)?(?P[^\s(]+))?" + r"(?:\s+(?:route-to|reply-to|dup-to)\s+\([^)]*\))?" + r"(?:\s+(?Pinet6?))?" + r"(?:\s+proto\s+(?P\{[^}]*\}|\S+))?" + r"\s+(?:(?Pall)\b|from\s+(?P" + ADDR + r")(?:\s+port\s+(?P" + PORT + r"))?" + r"\s+to\s+(?P" + ADDR + r")(?:\s+port\s+(?P" + PORT + r"))?)" +) + +# What the firewall answers on, read off `ifconfig`. Loopback and pf's own +# pseudo-interfaces hold no address a segment can be routed to. +SKIP_IFACES = re.compile(r"^(lo|pflog|pfsync|enc)\d*$") + +# The ranges the estate addresses itself from (RFC 1918 and ULA). An address the +# firewall holds outside them is its WAN side. +INTERNAL = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7")] + + +@dataclass(frozen=True) +class Rule: + action: str # pass | block + direction: str | None # in | out | None (both) + quick: bool + iface: str | None + iface_negated: bool + family: str | None + proto: str | None + src: str + sport: str | None + dst: str + dport: str | None + text: str + -def fetch_rules() -> list[str]: - """The running ruleset, in order. Order is the whole point — `quick` means - first match wins, so a block below the catch-all blocks nothing.""" +@dataclass(frozen=True) +class Address: + iface: str + ip: ipaddress.IPv4Address | ipaddress.IPv6Address + net: ipaddress.IPv4Network | ipaddress.IPv6Network + + +def ssh(script: str) -> str: + """Run a POSIX sh script on the firewall and return its stdout. + + The script goes on stdin to `sh -s` rather than on the command line, + because root's login shell on pfSense is not sh and the table loop below + is sh syntax.""" host = os.environ.get("FW_HOST", "10.0.99.1") user = os.environ.get("FW_USER", "root") result = subprocess.run( - ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", f"{user}@{host}", "pfctl -a '*' -sr"], + ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", f"{user}@{host}", "sh -s"], + input=script, capture_output=True, text=True, check=False, @@ -110,12 +187,45 @@ def fetch_rules() -> list[str]: if result.returncode != 0: detail = (result.stderr or result.stdout).strip().splitlines() sys.exit( - f"cannot read the ruleset from {user}@{host}: " + f"cannot read the firewall at {user}@{host}: " f"{detail[-1] if detail else 'ssh failed'}\n" f"This is a deploy-time check and needs a key that reaches the " f"firewall; it cannot run in CI, for the reason in the header." ) - return [NOISE.sub("", line).strip() for line in result.stdout.splitlines()] + return result.stdout + + +def clean(lines: list[str]) -> list[str]: + return [NOISE.sub("", line).strip() for line in lines] + + +def fetch() -> tuple[list[str], str, dict[str, list[str]]]: + """The running ruleset in order, `ifconfig`, and every table a rule names. + + Order is the whole point — `quick` means first match wins, so a block below + the catch-all blocks nothing. Table contents stay in memory: the WAN + network is in one of them, and nothing here prints an address it read.""" + marker = "@@HOMELAB-IFCONFIG@@" + out = ssh(f"pfctl -a '*' -sr\necho '{marker}'\nifconfig\n") + if marker not in out: + sys.exit("the firewall's answer has no ifconfig section — stopping rather than passing") + rules_text, ifconfig = out.split(marker, 1) + rules = clean(rules_text.splitlines()) + names = sorted({t for line in rules for t in re.findall(r"<(\w+)>", line)}) + tables: dict[str, list[str]] = {} + if names: + # A rule can name a table pf has not created (an empty alias, a VPN + # that is not configured); `-T show` fails on it, and that is an empty + # table, not a failed read. + script = "".join(f"echo '@@T {n}'; pfctl -t '{n}' -T show 2>/dev/null || :\n" for n in names) + current = None + for line in ssh(script).splitlines(): + if line.startswith("@@T "): + current = line[4:].strip() + tables[current] = [] + elif current is not None and line.strip(): + tables[current].append(line.strip()) + return rules, ifconfig, tables def derive(rules: list[str]) -> dict[str, dict]: @@ -156,7 +266,307 @@ def derive(rules: list[str]) -> dict[str, dict]: return posture +# --- Question 2: the firewall's own addresses --------------------------------- + + +def parse_ifconfig(text: str) -> list[Address]: + addresses: list[Address] = [] + iface = None + for line in text.splitlines(): + head = re.match(r"^(\S+?):\s+flags=", line) + if head: + iface = head.group(1) + continue + if iface is None or SKIP_IFACES.match(iface): + continue + v4 = re.match(r"^\s+inet\s+(\S+)\s+netmask\s+(0x[0-9a-fA-F]+)", line) + if v4: + ip = ipaddress.ip_address(v4.group(1)) + net = ipaddress.ip_network(f"{ip}/{int(v4.group(2), 16).bit_count()}", strict=False) + addresses.append(Address(iface, ip, net)) + continue + v6 = re.match(r"^\s+inet6\s+([0-9A-Fa-f:]+)(?:%\S+)?\s+prefixlen\s+(\d+)", line) + if v6: + ip = ipaddress.ip_address(v6.group(1)) + net = ipaddress.ip_network(f"{ip}/{v6.group(2)}", strict=False) + addresses.append(Address(iface, ip, net)) + return addresses + + +def parse_rules(lines: list[str]) -> tuple[list[Rule], list[str]]: + """Every pass and block rule, and the ones this parser could not read. + + `match`, `anchor`, `ether` and the rest are not decisions and are skipped. + An outbound rule is kept unparsed-tolerant: it never decides inbound.""" + rules: list[Rule] = [] + unreadable: list[str] = [] + for line in lines: + if not re.match(r"^(pass|block)\b", line): + continue + m = RULE.match(line) + if not m: + if not re.match(r"^(pass|block)\b(?:\s+\S+)*?\s+out\b", line): + unreadable.append(line) + continue + g = m.groupdict() + rules.append( + Rule( + action=g["action"], + direction=g["dir"], + quick=bool(g["quick"]), + iface=g["iface"], + iface_negated=bool(g["neg"]), + family=g["af"], + proto=g["proto"], + src="any" if g["all"] else g["src"].strip(), + sport=g["sport"], + dst="any" if g["all"] else g["dst"].strip(), + dport=g["dport"], + text=line, + ) + ) + return rules, unreadable + + +def port_number(name: str) -> int | None: + name = name.strip() + if name.isdigit(): + return int(name) + if name in MANAGEMENT: + return MANAGEMENT[name] + try: + return socket.getservbyname(name, "tcp") + except OSError: + return None + + +def port_matches(spec: str | None, port: int, lenient: bool) -> bool: + """Does `port` fall in a pfctl port spec? `lenient` answers the unknowns.""" + if spec is None: + return True + spec = spec.strip() + if spec.startswith("{"): + return any(port_matches(p, port, lenient) for p in spec.strip("{} ").replace(",", " ").split()) + m = re.match(r"^(!=|<=|>=|=|<|>)\s*(\S+)$", spec) + if m: + n = port_number(m.group(2)) + if n is None: + return lenient + return { + "=": port == n, + "!=": port != n, + "<": port < n, + "<=": port <= n, + ">": port > n, + ">=": port >= n, + }[m.group(1)] + m = re.match(r"^(\S+?)\s*(:|><|<>)\s*(\S+)$", spec) + if m: + lo, hi = port_number(m.group(1)), port_number(m.group(3)) + if lo is None or hi is None: + return lenient + return {":": lo <= port <= hi, "><": lo < port < hi, "<>": port < lo or port > hi}[m.group(2)] + n = port_number(spec) + return lenient if n is None else port == n + + +def carries_tcp(proto: str | None) -> bool: + if proto is None: + return True + return "tcp" in proto.strip("{} ").replace(",", " ").split() + + +def table_networks(entries: list[str]) -> list: + nets = [] + for entry in entries: + if entry.startswith("!"): + continue # a negated entry excludes; ignoring it errs toward matching + try: + nets.append(ipaddress.ip_network(entry.split()[0], strict=False)) + except ValueError: + continue + return nets + + +def contains(nets: list, target) -> bool: + if isinstance(target, (ipaddress.IPv4Network, ipaddress.IPv6Network)): + return any(n.version == target.version and target.subnet_of(n) for n in nets) + return any(n.version == target.version and target in n for n in nets) + + +def split_negation(spec: str) -> tuple[bool, str]: + if spec.startswith("!"): + return True, spec[1:].strip() + return False, spec + + +def source_matches(spec: str, own_macro: str, own_nets: list, tables: dict, lenient: bool) -> bool: + """Does a rule's source take in "a host on this segment"? + + Only `any`, the segment's own network macro, or something that contains the + whole of the segment's network does. A host or a narrower table is a + host-scoped exception, not a path for the segment.""" + negated, spec = split_negation(spec) + + def inner() -> bool: + if spec in ("any", "all"): + return True + if spec.startswith("{"): + return any( + source_matches(s, own_macro, own_nets, tables, lenient) + for s in spec.strip("{} ").replace(",", " ").split() + ) + if spec.startswith("<"): + name = spec.strip("<>") + if name == f"{own_macro}__NETWORK": + return True + if name not in tables: + return lenient + nets = table_networks(tables[name]) + return any(contains(nets, own) for own in own_nets) + if spec.startswith("(") or spec in ("self", "no-route"): + return False # the firewall's own address, never a segment host + try: + net = ipaddress.ip_network(spec, strict=False) + except ValueError: + return lenient + return any(contains([net], own) for own in own_nets) + + return (not inner()) if negated else inner() + + +def destination_matches(spec: str, target: Address, addresses: list[Address], tables: dict, lenient: bool) -> bool: + negated, spec = split_negation(spec) + + def inner() -> bool: + if spec in ("any", "all", "(self)", "self"): + return True + if spec == "no-route": + return False + if spec.startswith("{"): + return any( + destination_matches(s, target, addresses, tables, lenient) + for s in spec.strip("{} ").replace(",", " ").split() + ) + if spec.startswith("("): + name, _, modifier = spec.strip("()").partition(":") + held = [a for a in addresses if a.iface == name] + if not held: + return lenient + if modifier in ("network",): + return any(target.ip in a.net for a in held) + return any(target.ip == a.ip for a in held) + if spec.startswith("<"): + name = spec.strip("<>") + if name not in tables: + return lenient + return contains(table_networks(tables[name]), target.ip) + try: + net = ipaddress.ip_network(spec, strict=False) + except ValueError: + return lenient + return target.ip in net + + return (not inner()) if negated else inner() + + +def reaches( + rules: list[Rule], + iface: str, + own_macro: str, + own_nets: list, + target: Address, + port: int, + addresses: list[Address], + tables: dict, +) -> bool: + """pf's own evaluation, for one segment host, one firewall address, one port. + + First matching `quick` rule decides; otherwise the last matching non-quick + rule does; otherwise pf passes. Unknowns resolve toward the rule's action + being taken for a pass and skipped for a block — toward "reachable".""" + family = "inet" if target.ip.version == 4 else "inet6" + last: str | None = None + for r in rules: + if r.direction == "out": + continue + if r.iface is not None and (r.iface == iface) == r.iface_negated: + continue + if r.family is not None and r.family != family: + continue + if not carries_tcp(r.proto): + continue + lenient = r.action == "pass" + # A source-port condition narrows a block to less than "this segment", + # so it never covers; on a pass, client ports are ephemeral, so it counts. + if r.sport is not None and not lenient: + continue + if not source_matches(r.src, own_macro, own_nets, tables, lenient): + continue + if not destination_matches(r.dst, target, addresses, tables, lenient): + continue + if not port_matches(r.dport, port, lenient): + continue + if r.quick: + return r.action == "pass" + last = r.action + return last != "block" + + +def address_name(a: Address, segments: dict[str, str]) -> str: + """The firewall's address, named by where it sits and never by number.""" + if a.ip.is_link_local: + return "link-local" + if a.iface in segments: + return segments[a.iface] + # Not ipaddress's is_private, which counts the documentation and CGNAT + # ranges as private: anything outside the estate's own ranges is the WAN. + if not any(a.ip in n for n in INTERNAL): + return "WAN" + return a.iface + + +def derive_firewall( + rules: list[Rule], + posture: dict[str, dict], + addresses: list[Address], + tables: dict, + segments: dict[str, str], +) -> dict[str, dict[str, list[str]]]: + """Per interface: which of the firewall's addresses a host there reaches, + per management port, as names. A link-local address is only reachable from + its own link, so each interface is asked about its own and no other.""" + out: dict[str, dict[str, list[str]]] = {} + for iface, spec in posture.items(): + own_nets = [a.net for a in addresses if a.iface == iface and not a.ip.is_link_local] + targets = [a for a in addresses if not a.ip.is_link_local or a.iface == iface] + out[iface] = { + name: sorted( + { + address_name(t, segments) + for t in targets + if reaches(rules, iface, spec["macro"], own_nets, t, port, addresses, tables) + } + ) + for name, port in MANAGEMENT.items() + } + return out + + +# --- The claims file ---------------------------------------------------------- + + +def load_yaml(): + # PyYAML from the one pinned bootstrap, scripts/_deps.py (#848). Imported + # here rather than at the top so --self-test needs nothing beyond python3. + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + from _deps import require_yaml + + return require_yaml() + + def load_claims(path: pathlib.Path) -> dict: + yaml = load_yaml() if not path.is_file(): sys.exit(f"no claims file at {path} — run with --derive to write one") data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} @@ -165,45 +575,11 @@ def load_claims(path: pathlib.Path) -> dict: return data -def main() -> int: - ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - ap.add_argument("--claims", type=pathlib.Path, default=CLAIMS) - ap.add_argument( - "--derive", - action="store_true", - help="print the live posture as YAML instead of checking it", - ) - args = ap.parse_args() - - posture = derive(fetch_rules()) - if not posture: - sys.exit( - "no interface catch-all rules matched at all. Either every interface " - "was rewritten, or pfctl's output format changed and this parser is " - "reporting silence as success — which is the failure it exists to " - "prevent. Stopping rather than passing." - ) - - if args.derive: - # Segment names are the one thing the firewall cannot tell us — pf knows - # OPT4, not "ImaginationLAN" — so they are carried over from the existing - # claims file where it has them and left blank where it does not. - known = {} - if args.claims.is_file(): - known = (yaml.safe_load(args.claims.read_text(encoding="utf-8")) or {}).get("interfaces", {}) - out = { - iface: { - "segment": (known.get(iface) or {}).get("segment", "CHANGE ME"), - "wholesale": {f: v["wholesale"][f] for f in FAMILIES}, - } - for iface, v in posture.items() - } - print(yaml.safe_dump({"interfaces": out}, sort_keys=True, default_flow_style=False)) - return 0 - - claims = load_claims(args.claims) - declared = claims["interfaces"] +def compare(declared: dict, posture: dict, firewall: dict, claims_name: str) -> tuple[list[str], bool]: + """Print a line per assertion; return the failures, and whether any of them + is a segment that gained a path to the firewall itself.""" failures: list[str] = [] + opened = False def name(iface: str) -> str: return (declared.get(iface) or {}).get("segment", iface) @@ -211,7 +587,7 @@ def name(iface: str) -> str: # An interface nobody declared is the shape of "a VLAN was added and no # document mentions it", which is worth failing on rather than skipping. for iface in sorted(set(posture) - set(declared)): - failures.append(f"{iface} is on the firewall and not in {args.claims.name}") + failures.append(f"{iface} is on the firewall and not in {claims_name}") print(f"{RED} FAIL{RESET} {iface}: on the firewall, undeclared here") for iface in sorted(set(declared) - set(posture)): failures.append(f"{iface} is declared here and has no catch-all on the firewall") @@ -248,8 +624,111 @@ def name(iface: str) -> str: failures.append(msg) print(f"{RED} FAIL{RESET} {msg}") + want_fw = (declared[iface] or {}).get("firewall") + if want_fw is None: + msg = f"{iface} ({name(iface)}): no `firewall` declared — re-derive the claims file" + failures.append(msg) + print(f"{RED} FAIL{RESET} {msg}") + continue + for port in MANAGEMENT: + got = firewall[iface][port] + expected = sorted(want_fw.get(port) or []) + if expected == got: + shape = ", ".join(got) or "none of its addresses" + print(f"{GREEN} PASS{RESET} {iface} ({name(iface)}) {port}: reaches the firewall on {shape}") + continue + detail = [] + gained = sorted(set(got) - set(expected)) + closed = sorted(set(expected) - set(got)) + if gained: + opened = True + detail.append(f"now reaches the firewall on {port} at {plural(gained)}, which is not declared") + if closed: + detail.append(f"no longer reaches the firewall on {port} at {plural(closed)}") + msg = f"{iface} ({name(iface)}): " + "; ".join(detail) + failures.append(msg) + print(f"{RED} FAIL{RESET} {msg}") + return failures, opened + + +def plural(names: list[str]) -> str: + return f"its {names[0]} address" if len(names) == 1 else f"its {', '.join(names)} addresses" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--claims", type=pathlib.Path, default=CLAIMS) + ap.add_argument( + "--derive", + action="store_true", + help="print the live posture as YAML instead of checking it", + ) + ap.add_argument("--self-test", action="store_true", help="run the fixtures and exit") + args = ap.parse_args() + + if args.self_test: + return self_test() + + lines, ifconfig, tables = fetch() + posture = derive(lines) + if not posture: + sys.exit( + "no interface catch-all rules matched at all. Either every interface " + "was rewritten, or pfctl's output format changed and this parser is " + "reporting silence as success — which is the failure it exists to " + "prevent. Stopping rather than passing." + ) + rules, unreadable = parse_rules(lines) + if unreadable: + # Rule bodies are not printed, because a WAN rule's body carries the + # address. The position in `pfctl -a '*' -sr` is enough to find it. + sys.exit( + f"{len(unreadable)} inbound rule(s) this parser cannot read, so what reaches " + f"the firewall's own addresses cannot be answered. Stopping rather than " + f"passing. Lines of `pfctl -a '*' -sr`: " + ", ".join(str(lines.index(u) + 1) for u in unreadable) + ) + addresses = parse_ifconfig(ifconfig) + if not addresses: + sys.exit("ifconfig showed the firewall holding no address — stopping rather than passing") + + yaml = load_yaml() + known = {} + if args.claims.is_file(): + known = (yaml.safe_load(args.claims.read_text(encoding="utf-8")) or {}).get("interfaces", {}) + segments = {iface: spec["segment"] for iface, spec in known.items() if (spec or {}).get("segment")} + firewall = derive_firewall(rules, posture, addresses, tables, segments) + + if args.derive: + # Segment names are the one thing the firewall cannot tell us — pf knows + # OPT4, not "ImaginationLAN" — so they are carried over from the existing + # claims file where it has them and left blank where it does not. + out = { + iface: { + "segment": (known.get(iface) or {}).get("segment", "CHANGE ME"), + "wholesale": {f: v["wholesale"][f] for f in FAMILIES}, + "firewall": firewall[iface], + } + for iface, v in posture.items() + } + print(yaml.safe_dump({"interfaces": out}, sort_keys=True, default_flow_style=False)) + return 0 + + claims = load_claims(args.claims) + failures, opened = compare(claims["interfaces"], posture, firewall, args.claims.name) + if failures: sys.stdout.flush() + if opened: + # The one direction where the file is probably not the stale half. + # A segment reaching the firewall's own admin UI or sshd is a rule + # that names too little, the defect ADR-0117 closed; re-deriving + # would write the hole into the claims file and call it policy. + print( + "\nA segment reaches the firewall itself on a management port the claims " + "file does not grant. Treat that as a rule to fix before a claim to " + "re-derive: see docs/runbooks/close-the-firewall-to-its-segments.md.", + file=sys.stderr, + ) print( f"\n{len(failures)} claim(s) in {args.claims.name} disagree with the " f"running firewall.\nThe firewall is right and the file is stale — it " @@ -264,7 +743,7 @@ def name(iface: str) -> str: print( f"\nfirewall claims OK — {len(posture)} interface(s), " - f"{len(FAMILIES) * len(posture)} posture assertion(s) against the live ruleset" + f"{(len(FAMILIES) + len(MANAGEMENT)) * len(posture)} posture assertion(s) against the live ruleset" ) return 0 @@ -281,5 +760,221 @@ def name_for(declared: dict, posture: dict[str, dict], macro: str) -> str: return macro +# --- Fixtures ----------------------------------------------------------------- +# +# Synthetic, in the shape pfctl and ifconfig print. The WAN is 192.0.2.10, from +# the documentation range, so no fixture is a real rule body. Each case is the +# smallest ruleset that shows one way a block can close less than its name says. + +FIXTURE_IFCONFIG = """\ +em0: flags=1008843 metric 0 mtu 1500 +\tinet 192.0.2.10 netmask 0xfffffe00 broadcast 192.0.3.255 +\tinet6 fe80::1%em0 prefixlen 64 scopeid 0x1 +\tinet6 2001:db8::10 prefixlen 128 +igc0.99: flags=1008843 metric 0 mtu 1500 +\tinet 10.0.99.1 netmask 0xffffff00 broadcast 10.0.99.255 +\tinet6 fe80::2%igc0.99 prefixlen 64 scopeid 0x5 +igc0.50: flags=1008843 metric 0 mtu 1500 +\tinet 10.0.50.1 netmask 0xffffff00 broadcast 10.0.50.255 +\tinet6 fe80::2%igc0.50 prefixlen 64 scopeid 0x6 +igc0.30: flags=1008843 metric 0 mtu 1500 +\tinet 10.0.30.1 netmask 0xffffff00 broadcast 10.0.30.255 +\tinet6 fe80::2%igc0.30 prefixlen 64 scopeid 0x7 +lo0: flags=1008049 metric 0 mtu 16384 +\tinet 127.0.0.1 netmask 0xff000000 +""" + +FIXTURE_TABLES = { + "OPT1__NETWORK": ["10.0.99.0/24"], + "OPT2__NETWORK": ["10.0.50.0/24"], + "OPT4__NETWORK": ["10.0.30.0/24"], + "sshguard": [], +} + +DEFAULT_DENY = [ + "block drop in log inet all", + "block drop in log inet6 all", + "block drop in log quick proto tcp from to (self) port = ssh", + "pass out inet all flags S/SA keep state allow-opts", +] + +CATCH_ALL_99 = "pass in quick on igc0.99 inet from to any flags S/SA keep state" +CATCH_ALL_50 = "pass in quick on igc0.50 inet from to any flags S/SA keep state" +CATCH_ALL_30 = [ + "pass in quick on igc0.30 inet from to any flags S/SA keep state", + "pass in quick on igc0.30 inet6 from to any flags S/SA keep state", +] +SEGMENTS = {"igc0.99": "Winterfell", "igc0.50": "Hicks", "igc0.30": "ImaginationLAN"} + + +def _blocks(iface: str, dst: str, fam: str = "inet") -> list[str]: + return [ + f"block drop in quick on {iface} {fam} proto tcp from any to {dst} port = {p} flags S/SA" + for p in ("ssh", "https", "http") + ] + + +FIXTURES: list[tuple[str, list[str], str, dict[str, list[str]]]] = [ + ( + "a block naming its own address leaves the WAN address open (the 2026-11-13 finding)", + [ + "block drop in quick on igc0.99 inet proto tcp from to 10.0.99.1 port = https flags S/SA", + "block drop in quick on igc0.99 inet proto tcp from to 10.0.99.1 port = http flags S/SA", + "block drop in quick on igc0.99 inet from to ", + "block drop in quick on igc0.99 inet from to ", + CATCH_ALL_99, + ], + "igc0.99", + {"ssh": ["WAN", "Winterfell"], "http": ["WAN"], "https": ["WAN"]}, + ), + ( + "blocks to (self) close every address, and a host-scoped pass above them is not the segment's", + [ + "pass in quick on igc0.99 inet proto tcp from 10.0.99.20 to 10.0.99.1 port = ssh flags S/SA keep state", + *_blocks("igc0.99", "(self)"), + CATCH_ALL_99, + ], + "igc0.99", + {"ssh": [], "http": [], "https": []}, + ), + ( + "a named pass above the (self) blocks is the one way in", + [ + "pass in quick on igc0.50 inet proto tcp from to port = ssh flags S/SA keep state", + "pass in quick on igc0.50 inet proto tcp from to 10.0.99.1 port = https flags S/SA keep state", + *_blocks("igc0.50", "(self)"), + "pass in quick on igc0.50 inet proto tcp from to flags S/SA keep state", + "block drop in log quick on igc0.50 inet from to ", + CATCH_ALL_50, + ], + "igc0.50", + {"ssh": ["Winterfell"], "http": [], "https": ["Winterfell"]}, + ), + ( + "a segment-wide pass above the blocks opens that segment's gateway too", + [ + "pass in quick on igc0.50 inet proto tcp from to 10.0.99.1 port = https flags S/SA keep state", + "pass in quick on igc0.50 inet proto tcp from to flags S/SA keep state", + *_blocks("igc0.50", "(self)"), + "block drop in log quick on igc0.50 inet from to ", + CATCH_ALL_50, + ], + "igc0.50", + {"ssh": ["ImaginationLAN"], "http": ["ImaginationLAN"], "https": ["ImaginationLAN", "Winterfell"]}, + ), + ( + "an IPv4-only block leaves the IPv6 catch-all reaching the WAN and its own link-local", + [ + *_blocks("igc0.30", "(self)"), + "block drop in quick on igc0.30 inet from to ", + "block drop in quick on igc0.30 inet from to ", + *CATCH_ALL_30, + ], + "igc0.30", + {"ssh": ["WAN", "link-local"], "http": ["WAN", "link-local"], "https": ["WAN", "link-local"]}, + ), + ( + "IPv4+IPv6 blocks to (self) close both families", + [ + *_blocks("igc0.30", "(self)"), + *_blocks("igc0.30", "(self)", "inet6"), + "block drop in quick on igc0.30 inet from to ", + "block drop in quick on igc0.30 inet from to ", + *CATCH_ALL_30, + ], + "igc0.30", + {"ssh": [], "http": [], "https": []}, + ), + ( + "a pass to a port range does not open ssh, and an interface pass (anti-lockout) opens its own address", + [ + "pass in quick on igc0.99 proto tcp from any to (igc0.99) port = https flags S/SA keep state", + "pass in quick on igc0.99 inet proto tcp from to 10.0.99.1 port 2220:2229 flags S/SA keep state", + *_blocks("igc0.99", "(self)"), + CATCH_ALL_99, + ], + "igc0.99", + {"ssh": [], "http": [], "https": ["Winterfell", "link-local"]}, + ), + ( + "a block with a source-port condition covers nothing, and an unknown port name on a block covers nothing", + [ + "block drop in quick on igc0.99 inet proto tcp from any port = 0 to (self) port = ssh", + "block drop in quick on igc0.99 inet proto tcp from any to (self) port = no-such-service", + "block drop in quick on igc0.99 inet proto tcp from any to (self) port = https", + "block drop in quick on igc0.99 inet proto tcp from any to (self) port = http", + CATCH_ALL_99, + ], + "igc0.99", + {"ssh": ["Hicks", "ImaginationLAN", "WAN", "Winterfell"], "http": [], "https": []}, + ), +] + + +def self_test() -> int: + failed = 0 + addresses = parse_ifconfig(FIXTURE_IFCONFIG) + held = sorted(f"{a.iface} {a.ip}" for a in addresses) + want_held = sorted( + [ + "em0 192.0.2.10", + "em0 fe80::1", + "em0 2001:db8::10", + "igc0.99 10.0.99.1", + "igc0.99 fe80::2", + "igc0.50 10.0.50.1", + "igc0.50 fe80::2", + "igc0.30 10.0.30.1", + "igc0.30 fe80::2", + ] + ) + if held == want_held: + print(f"{GREEN} PASS{RESET} ifconfig: every address but loopback, with its interface") + else: + failed += 1 + print(f"{RED} FAIL{RESET} ifconfig: got {held}") + + for title, body, iface, want in FIXTURES: + lines = DEFAULT_DENY + body + rules, unreadable = parse_rules(lines) + posture = derive(lines) + if unreadable or iface not in posture: + failed += 1 + print(f"{RED} FAIL{RESET} {title}: unreadable {unreadable}, posture {sorted(posture)}") + continue + got = derive_firewall(rules, {iface: posture[iface]}, addresses, FIXTURE_TABLES, SEGMENTS)[iface] + if got == want: + print(f"{GREEN} PASS{RESET} {title}") + else: + failed += 1 + print(f"{RED} FAIL{RESET} {title}: got {got}, want {want}") + + _, unreadable = parse_rules(["pass in quick on igc0.99 inet proto tcp frum any to any"]) + if unreadable: + print(f"{GREEN} PASS{RESET} a rule the parser cannot read is reported, not skipped") + else: + failed += 1 + print(f"{RED} FAIL{RESET} an unreadable rule was skipped silently") + + wholesale = derive( + [ + "block drop in log quick on igc0.50 inet from to ", + "block drop in quick on igc0.50 inet proto tcp from to 10.0.50.1 port = ssh", + CATCH_ALL_50, + CATCH_ALL_99, + ] + ) + if wholesale["igc0.50"]["wholesale"] == {"inet": [], "inet6": []} and wholesale["igc0.99"]["wholesale"] == { + "inet": ["OPT2"], + "inet6": [], + }: + print(f"{GREEN} PASS{RESET} wholesale: a full-segment block covers, a port block does not count") + else: + failed += 1 + print(f"{RED} FAIL{RESET} wholesale: got {wholesale}") + + return 1 if failed else 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/self-tests.sh b/scripts/self-tests.sh index 8881351b..ce7fd0a9 100755 --- a/scripts/self-tests.sh +++ b/scripts/self-tests.sh @@ -55,6 +55,10 @@ # tree. /dev/shm is a refusal FIXTURE here, not a # destination: a different filesystem that is still # this host's RAM, which is the #596 regression (#610) +# check_firewall_claims.py what reaches the firewall's own addresses, which is +# a pf evaluation of a ruleset CI may never hold +# (ADR-0026); the shape that left every segment +# reaching the WAN address is a fixture (ADR-0117) # gitleaks-history.sh gitleaks's log text, which decides a pass; CI's full # clone never shows the zero-commit scan that the # fixture for the worktree regression stages diff --git a/systemd/homelab-firewall-claims.service b/systemd/homelab-firewall-claims.service index b110e054..60dcfeca 100644 --- a/systemd/homelab-firewall-claims.service +++ b/systemd/homelab-firewall-claims.service @@ -21,8 +21,10 @@ # # WHAT IT ASKS. Per interface and address family: which other segments does the # catch-all `pass ... to any` still reach, because no full-segment block sits -# above it. docs/firewall-claims.yaml declares the answer and this diffs it -# against `pfctl -a '*' -sr` read over SSH. It never writes a rule body anywhere +# above it. And per interface (ADR-0117): which of the firewall's own addresses +# does a host there reach on ssh, http and https. docs/firewall-claims.yaml +# declares both answers and this diffs them against `pfctl -a '*' -sr` and +# `ifconfig`, read over SSH. It never writes a rule body anywhere # — output names rules by descr only — which is what lets it run unattended # without violating docs/security.md's "rule bodies are not published". #