From 96bd7d142d3f58d08a72e53d15ae205dc603b8af Mon Sep 17 00:00:00 2001 From: Garrett Allen <98648590+Gerrrt@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:12:24 +0000 Subject: [PATCH] =?UTF-8?q?docs(adr):=20ADR-0041=20=E2=80=94=20terminate?= =?UTF-8?q?=20the=20remote=20path=20on=20the=20lab,=20and=20route=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The estate has no remote access, and nothing in docs/ describes an alternative. This designs one on a quiet afternoon rather than under time pressure: WireGuard terminating on the #436 jumpbox, on ImaginationLAN, reaching the lab only. Three things the issue did not carry: ADR-0022's second trigger is fired, not dodged. The narrow reading — that "any of it" means the sensitive tier, which a lab-only tunnel leaves unreachable — does not survive ADR-0014, whose reopening clause already says remote access into VLAN 30 "takes ADR-0008's no external access premise with it", and trigger 2 is that premise made testable. It is also wrong on the facts: stacks/lab publishes Grafana OSS on alexander, which ADR-0022's own table says can carry no second factor in any edition. So the trigger is recorded as fired and the deferral re-accepted with reasons, which is what ADR-0022 provides for. Routed, not masqueraded. NAT would collapse every peer into the jumpbox's address, so the Proxmox pass #436 needs on Saruman would extend to every peer and the tripwire would lose attribution. The cost is that a second source subnet arrives on igc0.30: the blocks and the tripwire are sourced from an interface network macro and LabSegmentReachedInternalNetwork hard-codes 10.0.30.x, so neither matches a peer. Both are widened here — unwidened, routed mode is strictly worse than NAT. The endpoint does not exist. The WAN address is DHCP-assigned, there is no dynamic DNS, and ADR-0011 measured "no rdr, no inbound WAN pass". Recorded as blocking and left to its own issue: a static IP is a recurring purchase and a DDNS provider is a third party handed a pointer to the house. 172.31.0.0/24 for the peers, /32 each. An earlier draft used 172.31.30.0/24 to echo the VLAN; rejected because it is one digit from ifrit's 172.30.30.0/24 and the two mean opposite things in a log line. 172.30. is the range, 172.31. is the tunnel. ADR-0014's clause says this gets a superseding ADR. It does not, and declining that is said out loud: the clause fired on a premise borrowed from ADR-0008, not on anything ADR-0014 decided, and every bullet it decided still governs. ADR-0007's note form is the precedent. ADR-0008, ADR-0011, ADR-0014 and ADR-0022 get forward pointers; no body is edited. Authored ahead of the host, as stacks/sensitive was ahead of trinity. The jumpbox does not exist and nothing here is deployed. Refs #442 Co-Authored-By: Claude Opus 5 --- README.md | 6 +- docs/adr/0008-place-services-by-data-trust.md | 9 + docs/adr/0011-keep-the-wiki-internal.md | 11 + ...nationlan-and-give-the-targets-no-route.md | 12 + ...-deferral-when-the-tier-holds-real-data.md | 14 + ...the-remote-path-on-the-lab-and-route-it.md | 297 +++++++++++++ docs/network.md | 22 + docs/observability.md | 12 + docs/roadmap.md | 9 +- docs/runbooks/open-the-remote-path.md | 397 ++++++++++++++++++ docs/runbooks/restore-the-firewall.md | 14 +- docs/security.md | 15 +- .../loki/rules/security.rules.yaml | 25 +- 13 files changed, 831 insertions(+), 12 deletions(-) create mode 100644 docs/adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md create mode 100644 docs/runbooks/open-the-remote-path.md diff --git a/README.md b/README.md index 31c85056..cfb49517 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ documents for different readers. the Makefile, the scripts, the workflow and the runbooks resolves its image from `compose.yaml` too, so an image that is not pinned there cannot be run at all. -- **Documented decisions and runbooks.** 40 ADRs covering what was chosen - and what was rejected — including the costs accepted knowingly; 25 +- **Documented decisions and runbooks.** 41 ADRs covering what was chosen + and what was rejected — including the costs accepted knowingly; 26 runbooks for the operations that are easy to get wrong at 1am, one of which is the handover page a successor reads first. @@ -207,7 +207,7 @@ Full topology and data flow in [`docs/architecture.md`](docs/architecture.md). ├── docs/ │ ├── architecture.md network.md hardware.md │ ├── observability.md security.md roadmap.md -│ ├── adr/ # 39 architecture decision records +│ ├── adr/ # 41 architecture decision records │ └── runbooks/ # successor handover (start here), deploy, converge, │ # add device, rotate creds, certs, key backup, │ # purge, restore the firewall, restore the stack, diff --git a/docs/adr/0008-place-services-by-data-trust.md b/docs/adr/0008-place-services-by-data-trust.md index 6cc65810..25cc60c5 100644 --- a/docs/adr/0008-place-services-by-data-trust.md +++ b/docs/adr/0008-place-services-by-data-trust.md @@ -119,3 +119,12 @@ earned its place. This leaves the "no MFA on the internal services" gap in revisited. Both changes push toward a dedicated services segment and toward Authelia, and both were declined here on the strength of *two users, no remote access*. When that premise changes, so does the decision. + + > *The second half of that premise changed in 2026-09. + > [ADR-0041](0041-terminate-the-remote-path-on-the-lab-and-route-it.md) opens + > a WireGuard path that terminates on the lab and reaches the lab only, so + > "no remote access" becomes "no remote access to the house". It fires + > [ADR-0022](0022-expire-the-sso-deferral-when-the-tier-holds-real-data.md)'s + > second trigger and re-accepts the deferral with reasons, which is what that + > ADR provides for. The placement reasoning above is unchanged and this ADR + > is not superseded.* diff --git a/docs/adr/0011-keep-the-wiki-internal.md b/docs/adr/0011-keep-the-wiki-internal.md index c56bdfd3..2e9076f8 100644 --- a/docs/adr/0011-keep-the-wiki-internal.md +++ b/docs/adr/0011-keep-the-wiki-internal.md @@ -58,6 +58,17 @@ machinery for publishing a service exists and reusing it would be cheap. > no `rdr` port forwards, and WAN (`em0`) has no inbound pass rules beyond DHCP > client replies. *"No port forward, no external hostname, no reverse-proxy > entry"* is the live state of the firewall, not only an intention. +> +> **Update · 2026-09.** The measurement above is now dated. +> [ADR-0041](0041-terminate-the-remote-path-on-the-lab-and-route-it.md) decides +> a WireGuard endpoint on the lab jumpbox, and building it makes `morpheus` +> carry exactly one `rdr` and one inbound WAN pass. **The decision below is +> unchanged and so is its reasoning**: the wiki is still not published, still +> has no external hostname and still sits behind no reverse proxy, and reaching +> it still means being inside the house — or, from ADR-0041 onward, on a tunnel +> that terminates on VLAN 30 and cannot route to `oracle` at all. What expires +> is the estate-wide claim that nothing is reachable from outside, not this +> ADR's answer for this service. ## Decision diff --git a/docs/adr/0014-put-ifrit-on-imaginationlan-and-give-the-targets-no-route.md b/docs/adr/0014-put-ifrit-on-imaginationlan-and-give-the-targets-no-route.md index 13f0800d..428843b0 100644 --- a/docs/adr/0014-put-ifrit-on-imaginationlan-and-give-the-targets-no-route.md +++ b/docs/adr/0014-put-ifrit-on-imaginationlan-and-give-the-targets-no-route.md @@ -11,6 +11,18 @@ > socketed RAM, and no backups, monitoring or patching for the range. Nothing > here is amended; the constraints below are what > [`build-the-playground.md`](../runbooks/build-the-playground.md) checks. +> +> --- +> +> **The last Consequence below names remote access into VLAN 30 as something +> that reopens this decision. It has been wanted, and +> [ADR-0041](0041-terminate-the-remote-path-on-the-lab-and-route-it.md), +> 2026-09, is the ADR that answers it.** What moved is the premise, not the +> decision: `ifrit` stays on ImaginationLAN, its targets still have no route, +> and every constraint below still holds. What ADR-0041 adds to this segment is +> a second source subnet — `172.31.0.0/24`, the WireGuard peers — which is why +> it also widens the tripwire this ADR decided. `172.30.` is the range; +> `172.31.` is the tunnel. Nothing here is amended. ## Context diff --git a/docs/adr/0022-expire-the-sso-deferral-when-the-tier-holds-real-data.md b/docs/adr/0022-expire-the-sso-deferral-when-the-tier-holds-real-data.md index 815a0cc8..8f97f940 100644 --- a/docs/adr/0022-expire-the-sso-deferral-when-the-tier-holds-real-data.md +++ b/docs/adr/0022-expire-the-sso-deferral-when-the-tier-holds-real-data.md @@ -2,6 +2,20 @@ **Status:** Accepted · 2026-09 +> [!NOTE] +> **Trigger 2 fired in 2026-09, and the deferral was re-accepted rather than +> ended.** +> [ADR-0041](0041-terminate-the-remote-path-on-the-lab-and-route-it.md) opens a +> WireGuard path terminating on the lab, which takes ADR-0008's *no external +> exposure* premise — the thing this trigger makes testable. It is recorded as +> fired there rather than argued around, and re-accepted on the grounds this +> ADR provides for: nothing in the table below becomes reachable, the tier is +> unbuilt, and the two-users premise is unchanged. What did become externally +> reachable is the lab's own Grafana on `alexander`, which the table below +> already condemns as unable to carry a second factor in any edition. +> **Triggers 1 and 3 are untouched and keep their full force.** Nothing here is +> amended. + ## Context [ADR-0008](0008-place-services-by-data-trust.md) defers Authelia knowingly and diff --git a/docs/adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md b/docs/adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md new file mode 100644 index 00000000..19e106bb --- /dev/null +++ b/docs/adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md @@ -0,0 +1,297 @@ +# ADR-0041: Terminate the remote path on the lab, and route it + +**Status:** Accepted · 2026-09 + +## Context + +The estate has no remote access. Every path in is a workstation physically on +Hicks, and that is a posture rather than an omission — it is the premise +[ADR-0008](0008-place-services-by-data-trust.md) deferred single sign-on on +("two users and no external exposure"), the premise +[ADR-0023](0023-keep-the-household-recovery-path-outside-the-estate.md) declined +a VPN under, and the state +[ADR-0011](0011-keep-the-wiki-internal.md) went and *measured* rather than +asserted: "`morpheus` carries no `rdr` port forwards, and WAN (`em0`) has no +inbound pass rules beyond DHCP client replies." + +Nothing in `docs/` describes an alternative, which is exactly why one should be +designed on a quiet afternoon rather than improvised from a hotel room. This +ADR designs it. [#442](https://github.com/Gerrrt/HomeLab/issues/442) is the +issue; the jumpbox it terminates on is +[#436](https://github.com/Gerrrt/HomeLab/issues/436), a guest on `Saruman` at a +static below `.100` on ImaginationLAN. + +### The trigger, and the temptation to read it narrowly + +[ADR-0022](0022-expire-the-sso-deferral-when-the-tier-holds-real-data.md) names +three triggers that end the SSO deferral. The second: + +> **Any of it becomes reachable from outside the house**, by any means, +> including a VPN terminating on 99. This is ADR-0008's *no external exposure* +> premise made testable. + +There is a narrow reading available, and it is tempting. Read against its +siblings — trigger 1 is "the sensitive tier holds real data", trigger 3 is "a +third person gets an account on any of it" — "it" looks like the sensitive +tier, and a tunnel that reaches `10.0.30.0/24` and stops makes none of +Vaultwarden, Immich, Paperless-ngx or Home Assistant reachable. On that reading +nothing fires and this ADR is a convenience with no consequences. + +**That reading does not survive contact with ADR-0014, which already decided +this question.** Its closing list of what reopens it contains this ADR by name: + +> or remote access into VLAN 30 is wanted, **which also takes ADR-0008's "no +> external access" premise with it**. + +So the chain is already written down, in two documents, by people who were not +thinking about a jumpbox at the time: remote access into VLAN 30 takes +ADR-0008's premise; trigger 2 *is* that premise made testable. Arguing that a +lab tunnel leaves the premise intact means arguing with ADR-0014's own +sentence. + +**And the narrow reading is wrong on the facts as well as the grammar.** +`stacks/lab/compose.yaml` runs Grafana OSS on `alexander` at `10.0.30.40`, +publishing `3000` to the segment, with one account and a password from SOPS. +ADR-0022's own table is what condemns it: "**Grafana OSS** — None, in any +edition… the documented route is an external identity provider." So a lab-only +tunnel does not merely touch the premise in the abstract. It makes an +authenticated, permanently MFA-incapable service reachable from outside the +house — which is the exact risk shape ADR-0022 was written about, on an +instance its table did not enumerate because `docs/security.md` counts service +types and there are two Grafanas. + +### Two properties of WireGuard that decide the rest + +**`AllowedIPs` is asymmetric.** On a client it is a *route* — which CIDRs go +down the tunnel. On a server it is an *access control list* — which source +addresses that peer may present. Getting them backwards is not a typo, it is a +segmentation failure, and a peer whose `AllowedIPs` is quietly wider than the +lab will not announce itself. + +**Forwarding is a capability, and it can be scoped to the tunnel's lifetime.** +Putting `net.ipv4.ip_forward` in `/etc/sysctl.conf` makes the jumpbox a router +permanently. Putting it in `PostUp`/`PostDown` makes it a router only while the +tunnel is up. + +### The property this estate has that most designs do not + +Most WireGuard subnet-router guides reach for `MASQUERADE` in `PostUp`, and +this estate should not. NAT rewrites every peer's source to the jumpbox's own, +and three things here read source addresses and would be lied to: + +| What reads the source | What NAT does to it | +| --- | --- | +| `Saruman`'s Proxmox firewall — ADR-0014 admits `8006`, `8007` and `22` from `10.0.50.0/24` only, and #436 needs a pass for the jumpbox | Every VPN peer inherits that pass, because every peer *is* the jumpbox on the wire | +| The ImaginationLAN tripwire (#234) and `LabSegmentReachedInternalNetwork` | A breach is still detected, but attributed to the jumpbox; which peer did it is unrecoverable | +| Suricata and `filterlog`, if the lab is ever watched | Same | + +## Decision + +**1. The tunnel terminates on the jumpbox, on ImaginationLAN, and reaches the +lab only.** Not on Winterfell, and not on `morpheus`. Terminating on the +firewall is the option this ADR most nearly took: it is the one device already +exposed to the WAN, it needs no port forward, and its restriction would be +enforced by the ruleset `pfctl -sr` can read back — the standard ADR-0013 and +ADR-0031 hold every other boundary to. It is declined because it puts a +listening daemon and a key store on the box whose failure is every segment at +once, which is the objection ADR-0010 raised against house-wide dependencies on +the gateway and ADR-0014's option 3 raised against routing lab traffic through +it. That is a close call and it is recorded as one. + +**The reach is enforced by the firewall, not by the jumpbox's good behaviour.** +This is the strongest property of the design and it is worth stating plainly: +ADR-0013 records that VLAN 30 blocks every other segment explicitly before its +egress rule, so **even a fully compromised jumpbox with `AllowedIPs = 0.0.0.0/0` +reaches the lab and the internet and nothing else in the house.** The jumpbox's +configuration is the second lock. #442's verification treats it as the only +one. + +**2. ADR-0022's trigger 2 is fired, and the deferral is re-accepted here, with +reasons.** ADR-0022 anticipated this: "Expiry means a decision gets recorded, +not that Authelia gets deployed… Re-accepting is a legitimate outcome — it is +what happened here once already." The reasons: + +- **Nothing in trigger 2's table becomes reachable.** The sensitive tier is + unbuilt, and when it is built it will be on Winterfell, which this tunnel + cannot route to. +- **The premise that actually carried ADR-0008's deferral is unchanged.** Two + users, no third account, and the services that hold data whose loss hurts are + still reachable only from inside. +- **What did become externally reachable is one Grafana on the lab**, holding + dashboards of a segment that exists to be broken. An identity provider in + front of it is the operational weight ADR-0008 was right about, for a service + whose compromise costs the lab. +- **The other two triggers are untouched and keep their force.** This ADR + spends trigger 2 and nothing else. + +Recording it this way costs a paragraph. Reading "it" narrowly costs the same +paragraph and leaves the next reader to re-derive an interpretation that +ADR-0014 contradicts. + +**3. Routed, not masqueraded.** No `MASQUERADE` rule, ever. Peers keep their +tunnel addresses across the jumpbox, `morpheus` carries one static route for +the tunnel subnet, and every rule and log line downstream sees a source that +names the peer. Forwarding still lives in `PostUp`/`PostDown`. + +This is also what makes the estate's own idiom work here. Under NAT a tunnel +address never appears on the wire at all, so "a source that should not exist is +a leak reporting itself" — ADR-0017's property for `172.30.30.0/24` — does not +transfer. Under routed mode it does, fully. + +The cost is real and accepted: **a second source subnet now arrives on +`igc0.30`, and every rule and alert that names `10.0.30.0/24` as a source has +to be re-read.** Two do. The tripwire is +`pass in log quick on igc0.30 inet from to `, +and `LabSegmentReachedInternalNetwork` hard-codes `,10\.0\.30\.[0-9]+,`. +Neither matches a tunnel peer, and the block rules above the tripwire do not +either — so left unwidened, tunnel traffic misses every block and the catch-all +carries it to the house. **Routed mode without that widening is strictly worse +than NAT**, and it is done in the same change that creates the tunnel. + +**4. The tunnel subnet is `172.31.0.0/24`, and every peer is pinned to a +`/32`.** Outside `10.0.0.0/16`, so a leak misses the ImaginationLAN pass rule +and lands on default deny — ADR-0014's reasoning for +[ADR-0017](0017-buy-ifrit-for-iops-and-keep-the-range-disposable.md)'s +`172.30.30.0/24`, applied again. Not `100.64.0.0/10`, which ADR-0017 reserves +as Tailscale's. Not `192.168.0.0/16` or `10.0.0.0/8`, where roaming clients sit +— a tunnel subnet that collides with the café is a tunnel that does not come +up. `172.31` is the top of Docker's default pool walk, which ADR-0017 used as +its argument for sitting high in `172.16/12`. + +**The second octet is the discriminator, and that is deliberate.** An earlier +draft used `172.31.30.0/24` to echo the VLAN it reaches. It was rejected: it is +one middle digit from `ifrit`'s `172.30.30.0/24`, and the two mean opposite +things in a log line at two in the morning — `172.30.30.x` in a block is a +never-patched target that has escaped a bridge with no physical port, an +incident and the scariest line this estate can produce; `172.31.x` in a block +is a peer's packet that did not route. Conflating them either raises a false +incident or, the one that costs, dismisses a real one as "that will be the +VPN". ADR-0017 chose its subnet so it "reads in a log line as what it is", and +a one-digit neighbour fails that test. **`172.30.` is the range. `172.31.` is +the tunnel.** + +**5. The endpoint is a prerequisite, and it is not decided here.** A WireGuard +server needs a reachable UDP endpoint, and this estate has no way to be one: +the WAN address is ISP-assigned by DHCP and there is no dynamic DNS anywhere in +it. The two answers — a static address from the ISP, which is a recurring +purchase, or a dynamic DNS provider, which means handing a third party a +continuously-updated pointer to a house whose WAN address +[`security.md`](../security.md) deliberately withholds — are a different +decision with a different argument, and folding them in here would bury them. +**Recorded as blocking, owned by its own issue.** + +**6. What is published about this, and what is not.** The tunnel subnet, the +design and the rules are documented. The endpoint hostname and the listen port +join the WAN address on `security.md`'s withheld list. A repository that +withholds the WAN address and then publishes the port a VPN listens on has +withheld nothing. + +### On ADR-0014, and why it is not superseded + +ADR-0014's clause says remote access into VLAN 30 "gets a superseding ADR". +**It does not get one, and declining that instruction deserves saying out loud +rather than doing quietly.** + +The clause fired on a premise ADR-0014 *borrowed* from ADR-0008 — which is why +the clause names ADR-0008 rather than any of ADR-0014's own bullets. Every +decision ADR-0014 made still governs and is still checked by +[`build-the-playground.md`](../runbooks/build-the-playground.md): `ifrit` +single-homed on VLAN 30, the targets on a bridge with no physical port and no +default route, the attack VM as the only dual-homed guest, egress unfiltered by +choice, and the `igc0.30` tripwire — which this ADR widens rather than removes. +Retiring a document whose every operative bullet holds, because a premise it +cited has moved, would make ADR-0014 look decided-against in every place that +cites it. + +The house already has the instrument for this and it is not supersession: +ADR-0007 is `Accepted` and carries a note block recording what later documents +amended, and ADR-0014's own header note uses the same form. That is what it +gets here. + +**ADR-0014's reopening clause mixes two kinds of condition in one sentence, and +that is worth naming for the next one.** "`ifrit` gains a trunk" would genuinely +supersede its Decision. "Remote access into VLAN 30 is wanted" cannot, because +it touches nothing ADR-0014 decided. A reopening clause is more useful when it +says which of *its own* decisions a condition puts back in play, and which +conditions are premise-watches that discharge into a pointer. + +## Consequences + +- **ADR-0011's measured fact stops being true the day this is built**, and its + decision does not. The wiki is still not published, still has no external + hostname and no reverse proxy — and a tunnel peer cannot reach `oracle` at + all, because `30 → 99` is default deny. ADR-0011's own reopening clause, that + wanting the wiki readable from outside should supersede it rather than be + argued as an exception, is **not** hit and is not being argued around. +- **ADR-0014's reopening clause has fired and is discharged, not superseded.** + ADR-0008 and ADR-0022 get forward pointers for the same reason: ADR-0008's + final consequence names this change ("if external access is ever wanted, this + should be revisited"), and ADR-0022 promises that a decision gets recorded at + the first trigger — a reader has to be able to find it from there. +- **The estate gains its first inbound path from the internet.** Everything in + the threat model that rested on "there is no way in" now rests on one UDP + listener and one keypair. WireGuard is a good bet for that job — it answers + nothing to an unkeyed probe, so the port is not discoverable by scanning — + but the bet is now being made, where before it was not. +- **The internet-facing host reports to the one telemetry store the house does + not read.** ADR-0007 keeps lab telemetry in the lab, and #436 sends the + jumpbox's Alloy to `alexander` and never to `10.0.99.20`. So every handshake + failure and every SSH auth failure on the estate's only externally reachable + host lands where no house alert looks. Either that becomes a #88-shaped + exception — ADR-0007's forbidden direction — or the exposure is unmonitored + by construction. **It is unmonitored by construction today**, and that is the + gap most likely to be discovered after it matters rather than before. +- **This re-scopes an accepted residual in another document.** `SECURITY.md` + accepts the end-of-life iLO on `shiva` (`10.0.30.10`, firmware 2.82) on the + reasoning that "a BMC compromise in the lab costs the lab". That was written + when the lab's attacker population was a VM the operator starts on purpose + and powers off afterwards. A remote path changes who can be on that segment, + and the residual was not written against this population. +- **It collides with #436's reason for existing, and that collision is + recorded, not resolved here.** ADR-0014 closes Proxmox `8006` to + `10.0.50.0/24` only, and ADR-0039 restates it: "Nothing on VLAN 30 gains a + path to `8006` on either hypervisor." #436's jumpbox is on VLAN 30 and exists + to hold a Proxmox API token. Either that rule widens — which changes an + ADR-0014 *decision* and makes ADR-0039's consequence false, and would need a + partial supersession rather than a note — or the jumpbox cannot do its job. + **That is #436's decision and it is not taken here**; this ADR records that + whichever issue lands first owns it, and that routed mode is what stops a + widened `8006` rule from silently admitting every VPN peer. +- **The jumpbox's blast radius is the hypervisor, and this raises the odds.** + Routed mode keeps a compromise legible afterwards; it does not make one less + likely. #436's choice to give the jumpbox its own Proxmox user and token + rather than `root@pam` is load-bearing for this ADR, not tidiness. +- **A peer is a credential, and there is no revocation story.** Removing one + means editing `wg0.conf` and reloading. With two or three devices that is + proportionate; it does not stay proportionate, and the first device that is + lost rather than retired is when that matters. The peer ACL also lives in one + file on one lab host, readable only by `wg show` — not in the firewall, where + `pfctl -sr` could read it back, and not under any CI assertion, which is out + of character for a repository that pins digests and greps its own history for + secrets. +- **`docs/firewall-claims.yaml` will stay green while under-describing this.** + Its shape is one `wholesale` list per interface, and `igc0.30`'s stays `[]` — + correct, because the tunnel adds no wholesale reach. What it cannot express + is "a second source subnet arrives on this interface", which is the fact that + matters. Recorded so a green `make check-firewall` is not read as more than + it is. +- **Nothing here is deployed.** The jumpbox does not exist; this ADR and its + runbook are authored ahead of the host, the way `stacks/sensitive` was + authored ahead of `trinity`. The decision is recorded now because the + alternative is deciding it while locked out. + +## What would reopen this + +- **The tunnel is wanted from Winterfell, or routed to the sensitive tier.** + That is trigger 2 fired a second time against the services it was written + for, and the re-acceptance above does not survive it: it would take an + identity provider or a new argument, not a rule change. +- **A third person gets a peer.** ADR-0022's trigger 3 is about accounts, and a + WireGuard peer is an account by another name. +- **The Proxmox `8006` rule widens to admit the jumpbox.** That reopens + ADR-0014 in its Decision rather than its premises, and ADR-0039 with it. +- **The peer list outgrows hand-editing**, or a device is lost rather than + retired — both make revocation a mechanism rather than a habit. +- **Anything on ImaginationLAN becomes something the house depends on.** + ADR-0014 put attack tooling there precisely because nothing does. A remote + path into that segment is cheap only while the segment stays disposable. diff --git a/docs/network.md b/docs/network.md index 79c0a1dc..8aaf6fbb 100644 --- a/docs/network.md +++ b/docs/network.md @@ -84,6 +84,16 @@ truth for what a box actually does. - Cat6 from the ISP gateway[^modem] to the WAN interface of the ProDesk[^ProDesk]. - The gateway runs in bridge mode; its own Wi-Fi radio stays operational but is unused. All wireless is handled by eero units on tagged VLANs. +- **One inbound pass, and one only** — the WireGuard endpoint decided by + [ADR-0041](adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md): a + UDP `rdr` to the lab jumpbox, terminating on ImaginationLAN and reaching the + lab only. The endpoint hostname and the listen port are withheld with the WAN + address ([`security.md`](security.md#what-this-repository-deliberately-does-not-publish)). + Before it, `morpheus` carried no `rdr` and no inbound WAN pass beyond DHCP + client replies — the state ADR-0011 measured in 2026-08. **Not built:** the + jumpbox ([#436](https://github.com/Gerrrt/HomeLab/issues/436)) does not exist + and the endpoint question is unanswered, so this describes a decision rather + than a rule on the box. [^modem]: [Xfinity Gateway (XB7)](https://www.xfinity.com/support/articles/broadband-gateways-userguides) [^ProDesk]: [HP ProDesk 600 G4 Mini](https://www.microcenter.com/product/692358/) @@ -381,6 +391,18 @@ Where things get broken on purpose. [`build-the-playground.md`](runbooks/build-the-playground.md), and `Saruman` moves to `10.0.30.20` as part of it. +- **The WireGuard peers live on `172.31.0.0/24`, and it is routed rather than + translated** + ([ADR-0041](adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md)). + `morpheus` carries one static route for it toward the jumpbox, so a peer's + own address is what arrives on this interface and what a firewall log + carries — which is what lets a rule name a peer and an alert say which one. + The blocks and the tripwire on this interface are therefore doubled: one set + sourced from the segment, one from the peers. It is the fourth private block + in the house and the second that is not a VLAN. **`172.30.` is `ifrit`'s + range bridge; `172.31.` is the tunnel** — they mean opposite things in a log + line, and the second octet is the only thing that distinguishes them. + > [!NOTE] > `10.0.30.10` is the iLO BMC, not the hypervisor, and it is what > `stacks/observability/prometheus/targets/snmp.yaml` polls — the `hypervisor-bmc` diff --git a/docs/observability.md b/docs/observability.md index 2fbabecb..884eeb7c 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -296,6 +296,18 @@ packet anyway, so nothing is weakened by it being there. The terminal three feed `LabSegmentReachedInternalNetwork`, which reads VLAN 30 as a source rather than a destination. +**That alert reads two source subnets, not one.** +[ADR-0041](adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md) routes +the WireGuard peers on `172.31.0.0/24` rather than masquerading them, so a +peer's own address reaches `igc0.30` and appears in `filterlog`. That is the +point of routing rather than translating — a peer is nameable in a rule and in +an alert — and the cost is that a source class written as `10.0.30.0/24` no +longer covers the interface. The alert's regex carries both; the interface +carries a second set of blocks and a second tripwire, sourced from the peers +rather than from the segment. A `10.0.30.x` source is the lab, a `172.31.x` +source is a remote peer, and a `172.30.x` source is neither — that is `ifrit`'s +range bridge escaping, which is a different incident. + A tripwire that never fires is indistinguishable from a broken one, which is this whole family of defect, so the logging path was proven rather than assumed: logging was briefly enabled on the lowest-volume terminal egress rule, and 49 of diff --git a/docs/roadmap.md b/docs/roadmap.md index 6de3c814..fe0367b7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1009,7 +1009,14 @@ them name the condition that would change the answer. and a larger concession on the management segment than SSH already is: it turns any browser session on Hicks into a potential path to every console in the estate, and it stores connection credentials to do it. The estate already - has a KVM in U6 for physical console access. Declined. + has a KVM in U6 for physical console access. Declined. **The remote-access question it + gestured at is answered differently** by + [ADR-0041](adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md): + WireGuard to the lab jumpbox, terminating on ImaginationLAN and reaching the + lab only. That is not a softening of this decline — it stores no connection + credentials at a gateway, it never touches Winterfell, and the boundary is + the firewall's rather than an application's. Guacamole's objection was about + the management segment, and nothing about ADR-0041 goes near it. - **Frigate** — locally-processed object detection on camera streams, and the one service on the shortlist that would change the network's shape rather than its population: continuous RTSP from every camera through the `99 → 20` diff --git a/docs/runbooks/open-the-remote-path.md b/docs/runbooks/open-the-remote-path.md new file mode 100644 index 00000000..b3d1c93a --- /dev/null +++ b/docs/runbooks/open-the-remote-path.md @@ -0,0 +1,397 @@ +# Runbook: Open the remote path + +**Target:** WireGuard on the jumpbox, ImaginationLAN (VLAN 30); one static +route, one port forward and five rules on `morpheus` (`10.0.99.1`) +**Time:** ninety minutes, across the jumpbox, the firewall GUI and one client +**You will need:** a shell on the jumpbox, the pfSense GUI, a client device to +enrol, and **an endpoint** — see §0, which is the step this runbook cannot do +for you +**Before this:** the jumpbox exists +([#436](https://github.com/Gerrrt/HomeLab/issues/436)), and +[ADR-0041](../adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md) +is read rather than skimmed + +This opens the estate's **first inbound path from the internet**. It terminates +on the lab and reaches the lab, and the thing that keeps it there is the +firewall's default deny rather than the jumpbox's configuration — but the +jumpbox's configuration is what decides whether the firewall is ever asked. Two +mistakes here do not announce themselves: a client `AllowedIPs` wider than the +lab, and a `MASQUERADE` rule copied in from a tutorial. + +> [!CAUTION] +> **There is no `MASQUERADE` in this design, anywhere.** Every guide you will +> find while doing this has one. ADR-0041 §2 is why this one does not: NAT +> would make every peer indistinguishable from the jumpbox, which holds a +> Proxmox token and an SSH key, and would hand every peer the jumpbox's +> standing at the firewall. If you find yourself adding `-j MASQUERADE` to make +> something work, the missing piece is the static route in §5, not the NAT. + +## What moves where, and what never does + +| Artefact | Made on | Lives on | Travels? | +| --- | --- | --- | --- | +| The server private key | The jumpbox | The jumpbox, in `/etc/wireguard/` at `0600` | **Never.** Generated where it is used | +| The server public key | The jumpbox | Every peer's config | Yes, freely. Public | +| Each peer's private key | **That peer's own device** | That device | **Never**, including not to the jumpbox | +| Each peer's public key | That device | `wg0.conf` on the jumpbox | Yes, freely. Public | +| The preshared key, one per peer | Either end, with `wg genpsk` | Both ends of that one peer | Once, over a channel that is not email | +| The endpoint hostname and listen port | §0 | `docs/security.md`'s withheld list | **Not into this repository** | + +Generating a peer's key *on the jumpbox* and sending it to the device is the +obvious shortcut and it is the one thing this table exists to forbid. A private +key that has been on two machines is a key you cannot reason about later. + +--- + +## 0. The endpoint — the prerequisite this runbook does not solve + +WireGuard needs a stable address and port to dial. This estate has neither: the +WAN address is ISP-assigned by DHCP, and there is no dynamic DNS anywhere in +it. ADR-0041 §4 records that deliberately as a separate decision — a static +address is a recurring purchase, and a dynamic DNS provider is a third party +handed a continuously-updated pointer to the house. + +**Do not start §1 until that is answered.** Everything below assumes you have +one of: + +- a static WAN address from the ISP, or +- a dynamic DNS hostname, updated by `morpheus` under *Services → Dynamic DNS*. + +Pick a listen port that is not a well-known one. It goes on `security.md`'s +withheld list beside the WAN address, not into a commit message. + +> [!NOTE] +> Behind CGNAT neither option works, and no amount of firewall configuration +> fixes it — an inbound port forward needs an address the ISP actually routes +> to you. If the WAN address is in `100.64.0.0/10`, stop here: the answer is a +> conversation with the ISP, or an outbound-only overlay, which is a different +> ADR. + +## 1. Install WireGuard on the jumpbox + +```bash +# The distro package, not a container. The jumpbox runs no compose stack, and +# a kernel-module datapath in Docker would need privileges worth more than the +# convenience. +sudo apt-get update && sudo apt-get install --yes wireguard + +# Expect a version, and no error. The module loads on first use, not now. +wg --version +``` + +> [!NOTE] +> **If this host ever runs Docker, pin its address pools first.** Docker walks +> `172.16/12` upward from `172.17` when it allocates a bridge network, and +> `172.31.0.0/24` is at the very top of that walk — the last thing it would +> take, and still something it can take. A collision here is a tunnel that +> stops routing the day someone runs `docker compose up` on the toolchain host. +> Set `default-address-pools` in `/etc/docker/daemon.json` to a base that +> excludes it. + +## 2. Generate the server keypair, on the jumpbox + +```bash +# umask FIRST. wg genkey writes through your shell's redirect, and the default +# umask leaves the private key world-readable for the instant before chmod. +sudo install -d -m 0700 /etc/wireguard +( umask 077 && wg genkey | sudo tee /etc/wireguard/server.key >/dev/null ) +sudo chmod 0600 /etc/wireguard/server.key +sudo sh -c 'wg pubkey < /etc/wireguard/server.key > /etc/wireguard/server.pub' + +# The public half, for the peer configs in §4. +sudo cat /etc/wireguard/server.pub +``` + +Verify the mode before moving on — this is the one file whose permissions +matter and the one people fix later: + +```bash +sudo stat -c '%a %n' /etc/wireguard/server.key +# expect: 600 /etc/wireguard/server.key +``` + +## 3. The server configuration + +`/etc/wireguard/wg0.conf`, mode `0600`. Substitute the bracketed values; the +listen port is the one from §0. + +```ini +[Interface] +# The router's own address inside the tunnel. /24 here is the interface's +# subnet, not a grant to anybody. +Address = 172.31.0.1/24 +ListenPort = +PrivateKey = + +# Forwarding is a capability scoped to the tunnel's lifetime, not a permanent +# property of the host (ADR-0041). Nothing here is in /etc/sysctl.conf, and +# nothing here translates an address. +PostUp = sysctl -w net.ipv4.ip_forward=1 +PostDown = sysctl -w net.ipv4.ip_forward=0 + +[Peer] +# laptop-01. One block per device, and the comment is how you will know which +# key to delete in a year. +PublicKey = +PresharedKey = +# On the SERVER, AllowedIPs is an ACCESS CONTROL LIST: the only source address +# this peer is permitted to present. One /32 per device. A /24 here would let +# any peer impersonate any other. +AllowedIPs = 172.31.0.2/32 +``` + +```bash +sudo chmod 0600 /etc/wireguard/wg0.conf +sudo systemctl enable --now wg-quick@wg0 +``` + +## 4. The client configuration + +On the **client device**, generate its own keypair and build this. Send the +public key to the jumpbox for §3; the private key stays where it was made. + +```ini +[Interface] +Address = 172.31.0.2/32 +PrivateKey = + +[Peer] +PublicKey = +PresharedKey = +Endpoint = : +# On the CLIENT, AllowedIPs is a ROUTE: the CIDRs that go down the tunnel. +# This is the lab and nothing else. 0.0.0.0/0 here would pull all of the +# device's traffic through the house, which is not what this is for and is the +# failure ADR-0041 says will not announce itself. +AllowedIPs = 10.0.30.0/24 +PersistentKeepalive = 25 +``` + +> [!IMPORTANT] +> **`AllowedIPs` means opposite things on the two ends, and both are on this +> page.** Server: an ACL, `/32`, "who may this peer claim to be". Client: a +> route, `10.0.30.0/24`, "what goes down the tunnel". Reading §3's value into +> §4 gives a peer that can reach nothing; reading §4's into §3 gives a peer +> that can present any source address in the tunnel subnet. + +## 5. The route back, on `morpheus` + +Without this, lab hosts receive tunnel packets and answer them to their default +gateway, which has never heard of `172.31.0.0/24`. This is the step that +replaces the NAT rule. + +*System → Routing → Gateways → Add* — a gateway on the ImaginationLAN +interface pointing at the jumpbox: + +| Field | Value | +| --- | --- | +| Interface | ImaginationLAN | +| Address Family | IPv4 | +| Name | `JUMPBOX_TUNNEL` | +| Gateway | the jumpbox's lab address | +| Disable Gateway Monitoring | **checked** — it is a host, not an uplink, and a failed ping should not mark it down | + +Then *System → Routing → Static Routes → Add*: + +| Field | Value | +| --- | --- | +| Destination network | `172.31.0.0/24` | +| Gateway | `JUMPBOX_TUNNEL` | +| Description | `WireGuard peers — ADR-0041` | + +**Save**, then **Apply Changes**. + +## 6. The inbound pass, on the WAN + +*Firewall → NAT → Port Forward → Add*: + +| Field | Value | +| --- | --- | +| Interface | WAN | +| Protocol | **UDP** | +| Destination | WAN address | +| Destination port range | the listen port, from and to | +| Redirect target IP | the jumpbox's lab address | +| Redirect target port | the same listen port | +| Description | `WireGuard — ADR-0041` | +| Filter rule association | **Add associated filter rule** | + +**Save**, then **Apply Changes**. This is the `rdr` and the WAN pass that +[ADR-0011](../adr/0011-keep-the-wiki-internal.md)'s 2026-08 measurement said +did not exist; its update note records that this is what changed it. + +## 7. Teach the segmentation about the second subnet + +**This section is not optional, and skipping it is worse than having used +NAT.** Routed mode puts a second source subnet on `igc0.30`. Every existing +block rule and the tripwire are scoped `from ` — that is +`10.0.30.0/24` and it does not match a tunnel peer. Left as-is, tunnel traffic +misses every block above the catch-all and the catch-all passes it to every +segment in the house. + +First, *Firewall → Aliases → IP → Add*: + +| Field | Value | +| --- | --- | +| Name | `Tunnel_Peers` | +| Type | Network(s) | +| Network | `172.31.0.0/24` | +| Description | `WireGuard peers — ADR-0041` | + +Then on *Firewall → Rules → ImaginationLAN*, mirror the existing lab rules for +this source, keeping the established order — blocks, then the tripwire, then +the egress catch-all: + +1. **A block per house segment**, `Tunnel_Peers → `, logged, placed + immediately beside the existing `10.0.30.0/24` blocks. +2. **One tripwire**, `pass` + `log`, `Tunnel_Peers → House_Segments`, directly + below those blocks and above the `→ any` egress rule. + +The tripwire points at `House_Segments`, **not** `Internal_Segments` — the +latter names `10.0.30.0/24` itself, and against it every DNS query from a peer +to the lab gateway logs as a crossing. That mistake cost 1,239 false lines in +three days when the lab's own tripwire was created +([#234](https://github.com/Gerrrt/HomeLab/issues/234)); do not repeat it here. + +Then widen the alert that watches it — +`stacks/observability/loki/rules/security.rules.yaml`, +`LabSegmentReachedInternalNetwork` — so a tunnel source counts as the lab +reaching the house. The rule and its unit test are changed in the same commit +as this runbook; `make check-loki-rules` proves it. + +## 8. Verify — up, and then down + +The tunnel being up proves almost nothing. What has to be proved is that it +reaches the lab, that it reaches nothing else, and that the capability goes +away with it. + +```bash +# On the jumpbox, with the tunnel up. The handshake is the only proof the keys +# and the endpoint agree; an interface can exist and be useless. +sudo wg show wg0 +# expect: a peer, a recent handshake, and non-zero transfer in both directions +``` + +```bash +# THE CHECK THIS RUNBOOK EXISTS FOR. Read AllowedIPs off the RUNNING +# interface, not off the file you think you deployed. +sudo wg show wg0 allowed-ips +# expect exactly one /32 per peer, all inside 172.31.0.0/24. +# Anything wider — a /24, or 0.0.0.0/0 — is the segmentation failure. +``` + +From the **client**, with the tunnel up: + +```bash +# Reaches the lab. +ping -c1 10.0.30.1 + +# Reaches NOTHING else. Each of these must fail, and must fail by timing out +# rather than by "network unreachable" from your own machine — the latter means +# the client route is right and you have not tested the firewall at all. +ping -c1 -W3 10.0.99.20 # Winterfell — the observability host +ping -c1 -W3 10.0.99.30 # Winterfell — oracle +ping -c1 -W3 10.0.50.10 # Hicks +ping -c1 -W3 10.0.40.30 # CasaBonita +``` + +Now bring it down, and check the capability went with it: + +```bash +sudo systemctl stop wg-quick@wg0 + +# Forwarding is off, because PostDown turned it off. +sysctl net.ipv4.ip_forward +# expect: net.ipv4.ip_forward = 0 + +# NO MASQUERADE RULE EXISTS — with the tunnel down or up. This is the check +# #442 asks for by name. Both must print nothing at all. +sudo iptables-save 2>/dev/null | grep -i masquerade +sudo nft list ruleset 2>/dev/null | grep -i masquerade +``` + +And from the client, with the tunnel down, the lab is gone: + +```bash +ping -c1 -W3 10.0.30.1 +# expect: failure. If this succeeds, you are on the house network, not remote — +# test from a device that is genuinely off-estate, on a phone hotspot. +``` + +### The leak drill + +`build-the-playground.md` gives the range a drill that proves a leak would +report itself. The tunnel gets the sibling, and for the same reason: routed +mode means a peer's address appears on the wire, so a packet that escapes the +lab carries a source that cannot be anything else. + +With the tunnel up, from the client, aim one packet at a segment the tunnel +must not reach — `ping -c3 -W3 10.0.99.20` — and then, on the monitoring host: + +```bash +curl -sG http://localhost:3100/loki/api/v1/query \ + --data-urlencode 'query=sum by (src) (count_over_time({app="filterlog", action="block"} | regexp `,(?P\d+\.\d+\.\d+\.\d+),(?P\d+\.\d+\.\d+\.\d+),` | src =~ "172\\.31\\..+" [15m]))' \ + | jq '.data.result' +``` + +A row with a `172.31.x` source is the property working: the firewall stopped it +*and* said which peer tried. **An empty result is not a pass** — it means +either the packet never left the client (check the client's `AllowedIPs` +route), or the jumpbox never forwarded it, and you should find out which before +concluding the boundary held. + +Addresses are parsed at query time rather than indexed +([ADR-0003](../adr/0003-observability-stack-selection.md)), which is why this +reaches for `regexp` rather than a label. + +> [!IMPORTANT] +> **Test from off-estate, once, properly.** Every check above passes from a +> Hicks workstation whether or not the tunnel works, because Hicks reaches +> ImaginationLAN anyway (ADR-0031). A verification that cannot fail has not +> verified anything. + +## Rollback + +In reverse, and safe at every step — the estate's posture before this runbook +is strictly more closed than after it. + +1. `sudo systemctl disable --now wg-quick@wg0` on the jumpbox. The inbound path + is dead from here; everything below is tidying. +2. Delete the port forward and its associated filter rule (§6). +3. Delete the static route and the gateway (§5). +4. Delete the `Tunnel_Peers` rules and the alias (§7). +5. Revert the Loki rule change. +6. `sudo shred -u /etc/wireguard/server.key /etc/wireguard/wg0.conf`. + +Removing **one peer** rather than the tunnel is a `wg0.conf` edit and +`sudo systemctl reload wg-quick@wg0`. ADR-0041 records that this does not scale +and that the first lost device is when it stops being proportionate. + +## If something goes wrong + +| Symptom | Cause | Fix | +| --- | --- | --- | +| No handshake, ever | The port forward is not reaching the jumpbox, or the endpoint is stale | `sudo tcpdump -ni any udp port ` on the jumpbox while the client retries. No packets means §6 or the endpoint; packets but no handshake means the keys | +| Handshake succeeds, nothing routes | The static route in §5 is missing — replies are going to the lab's default gateway, which has never heard of the tunnel subnet | Add it. **Do not add a NAT rule to make this work** | +| Handshake succeeds, lab reachable, but only from the jumpbox itself | `PostUp` did not run or forwarding is off | `sysctl net.ipv4.ip_forward` — expect 1 with the tunnel up | +| The client reaches the whole internet through the house | `AllowedIPs = 0.0.0.0/0` on the client | §4. This is the wide-`AllowedIPs` failure, and it is silent | +| The client reaches Winterfell | A block in §7 is missing or ordered below the catch-all | Check rule order on the ImaginationLAN interface. Treat as a live segmentation failure and read the tripwire log | +| `LabSegmentReachedInternalNetwork` fires | Either a real breach, or the rule was widened without the blocks | Both are urgent. Read the `filterlog` line: a `172.31.0.x` source is a peer, a `10.0.30.x` source is the lab | +| Everything works from the sofa and nothing from a hotel | You tested from inside the house | See §8's note. Hicks reaches the lab without any tunnel | + +## What this does not do + +- **It does not reach Winterfell, and it must not.** ADR-0022's second trigger + ends the SSO deferral the moment the sensitive tier is reachable from outside + the house. Terminating on 99, or routing the tunnel to the tier, fires it — + that is a new ADR and an identity provider, not a rule change. +- **It does not authenticate a person.** A peer is a device with a key. There + is no second factor and no account behind it; losing the device is losing the + credential. +- **It does not revoke.** See Rollback. +- **It does not watch the WAN.** Suricata is not on that interface and + `docs/security.md` says it deliberately never will be. The tunnel's inside is + watched by the §7 tripwire; its outside is not watched at all. +- **It does not give the lab a route to the peers.** Traffic is initiated from + the peer. Nothing on ImaginationLAN can open a connection to a device on the + tunnel, and nothing should want to. diff --git a/docs/runbooks/restore-the-firewall.md b/docs/runbooks/restore-the-firewall.md index 439e5d09..855278e2 100644 --- a/docs/runbooks/restore-the-firewall.md +++ b/docs/runbooks/restore-the-firewall.md @@ -267,7 +267,19 @@ ssh root@10.0.99.1 'pfctl -sr \ # latter names 10.0.30.0/24 itself, and against it every DNS query from the lab # to its own gateway logs as a crossing. ssh root@10.0.99.1 'pfctl -sr | grep -E "^pass in log quick on igc0\.30 " | grep -c ""' -# expect 1 +# expect 2 once ADR-0041's tunnel exists: the lab's own tripwire and the +# WireGuard peers'. Before it is built, expect 1. + +# 6. ADR-0041's rules, if the tunnel has been built. The check above counts +# tripwires by their shape and the four in step 5 are sourced from an +# interface network macro, which the tunnel's is not — so the tunnel's +# tripwire and its blocks need asking after separately, or a restore drops +# them as silently as it drops the other four. +ssh root@10.0.99.1 'pfctl -sr | grep -c ""' +# expect 0 if the tunnel is not built. Once it is: one tripwire plus one block +# per house segment, and `pfctl -t Tunnel_Peers -T show` must print the peer +# subnet rather than an empty table — an alias that survived with no contents +# makes every rule using it match nothing, which reads as "no leaks". ``` > [!NOTE] diff --git a/docs/security.md b/docs/security.md index 9709cb7e..31ed298d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -35,7 +35,14 @@ internal services. ends [ADR-0008](adr/0008-place-services-by-data-trust.md)'s SSO deferral on a state rather than leaving it open: the first real secret, photo or document in the sensitive tier, any reachability from outside the house, or a third account -holder — whichever comes first. Until then the floor is per-application TOTP, +holder — whichever comes first. **The second of those has now fired and the +deferral was re-accepted**, not ended: +[ADR-0041](adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md) opens +a WireGuard path terminating on the lab, which takes ADR-0008's *no external +exposure* premise with it. Nothing in the tier became reachable — it is +unbuilt, and on Winterfell when it is built — but the lab's own Grafana on +`alexander` did, and that is one of the three below that cannot carry a factor +at all. The other two triggers keep their full force. Until then the floor is per-application TOTP, and it does not reach everything. Vaultwarden, Paperless-ngx and Home Assistant can each carry a second factor; **Grafana, Immich and AdGuard Home cannot** — Grafana OSS has no MFA in any edition, Immich's upstream has declined it and @@ -658,6 +665,12 @@ fingerprint of a house is not. Withheld on purpose: - **Camera-to-room mapping.** Knowing there are seven cameras is fine. Knowing which one covers which door is a physical-security detail. - **The WAN address**, firewall rule bodies, and Wi-Fi configuration. +- **The WireGuard endpoint and its listen port**, for the same reason and with + the same instinct: a repository that withholds the WAN address and then + publishes the port a VPN answers on has withheld nothing. The design, the + peer subnet and the rules are published + ([ADR-0041](adr/0041-terminate-the-remote-path-on-the-lab-and-route-it.md)); + where to send a packet is not. The public IP was already redacted in the original inventory — the rest of this is the same instinct applied consistently. diff --git a/stacks/observability/loki/rules/security.rules.yaml b/stacks/observability/loki/rules/security.rules.yaml index 49237569..8d3a8568 100644 --- a/stacks/observability/loki/rules/security.rules.yaml +++ b/stacks/observability/loki/rules/security.rules.yaml @@ -351,11 +351,21 @@ groups: # # 10.7.7.0/24 is the untagged switch-management LAN, in both aliases # since 2026-09-06; a logged block above the tripwire catches it first. + # + # 172.31.0.0/24 is the WireGuard peer subnet (ADR-0041). Routed, not + # masqueraded, so a peer's own address is what arrives on igc0.30 and + # what a filterlog line carries — which is the point of routing rather + # than translating, and the reason this source class has two members + # rather than one. A peer reaching the house is the same breach as the + # lab reaching the house and fires the same alert; the source octet is + # what says which. `172.30.` is ifrit's range bridge and is NOT here: + # that source means a target escaped a bridge with no physical port, + # which is a different incident (ADR-0017, build-the-playground.md). expr: | sum by (interface) ( count_over_time( {app="filterlog", action="pass"} - |~ `,10\.0\.30\.[0-9]+,(10\.0\.(10|20|40|50|99)|10\.7\.7)\.[0-9]+,` [5m] + |~ `,(10\.0\.30|172\.31\.0)\.[0-9]+,(10\.0\.(10|20|40|50|99)|10\.7\.7)\.[0-9]+,` [5m] ) ) > 0 for: 0s @@ -366,11 +376,14 @@ groups: annotations: summary: "Segmentation breach: the lab passed to the house on {{ $labels.interface }}" description: >- - A packet from VLAN 30 was PASSED toward another segment. ADR-0014 - puts attackers on that VLAN and says this path does not exist: - the segment that holds attack tooling has reached the house. Either - a block rule was removed or reordered, or the tripwire's alias - gained the wrong subnet. Treat as a live segmentation failure. + A packet from VLAN 30 or from the WireGuard peer subnet was + PASSED toward another segment. ADR-0014 puts attackers on that VLAN + and says this path does not exist: the segment that holds attack + tooling has reached the house. Either a block rule was removed or + reordered, or the tripwire's alias gained the wrong subnet. A + 10.0.30.x source is the lab itself; a 172.31.x source is a remote + peer, which also means the ADR-0041 blocks are missing or ordered + below the catch-all. Treat as a live segmentation failure. - alert: IoTAttemptedLateralMovement # The mirror image: correctly BLOCKED attempts out of the IoT segment.