From 08054ed25aa382d6d3bbeac92abbd9e24c16b608 Mon Sep 17 00:00:00 2001 From: Garrett Allen <98648590+Gerrrt@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:53:43 +0000 Subject: [PATCH] docs: six VLANs and the untagged LAN, and count them from the table (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `morpheus` routes six 802.1Q VLANs. Six places said seven — one more than #209 found, and the extra one is the reason: the claim in restore-the-firewall.md wraps as "all seven\nVLANs", so a line-by-line search never saw it. #209's own table missed ADR-0008 for the same reason it missed nothing else. Seven is nearly right and the fix keeps what is true in it. `morpheus` routes seven internal networks: six VLANs plus the untagged switch-management LAN at 10.7.7.1/24, which network.md's table has always recorded with a dash because it carries no tag. "Seven networks" is true; "seven VLANs" is not. The runbook gets the wording #209 suggested, because that is the load-bearing one: someone restoring six VLANs from a document saying seven goes looking for a missing one, and the untagged LAN — the segment the switch is managed from, needed precisely when a VLAN is misconfigured — is what gets missed. Both ADRs are corrected in place rather than annotated, which is the opposite of how #161 treated ADR-0008's "long earmarked as the spare". That was an accurate record of a belief that shaped a decision. This was never true: ADR-0002's own table has enumerated exactly six since it was written, so the sentence contradicted the document containing it. Neither decision changes. The count now comes from network.md's segment table, read off the tag column so the untagged LAN stays uncounted and "seven internal networks" stays sayable. check_counts scanned line by line, so every claim in it was blind to a claim that wraps — including the receiver count added for #212 a few commits ago. It now matches against whole files, with a whitespace run that may cross one line break and never a blank line, so a count ending a paragraph cannot bind to a noun starting the next. Co-Authored-By: Claude Opus 5 --- README.md | 7 +- docs/adr/0002-vlan-segmentation-strategy.md | 2 +- docs/adr/0008-place-services-by-data-trust.md | 2 +- docs/network.md | 4 +- docs/runbooks/restore-the-firewall.md | 6 +- scripts/check_docs.py | 83 +++++++++++++------ 6 files changed, 70 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 7534335..0eee213 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ --- -Seven VLANs behind a pfSense firewall, default-deny between every segment, with -a Prometheus/Loki/Grafana stack watching all of it. Every config in this +Six VLANs and the untagged switch-management LAN behind a pfSense firewall, +default-deny between every segment, with a Prometheus/Loki/Grafana stack +watching all of it. Every config in this repository is the config that runs, validated on every push. It started as a place to practise security work and turned into the network the @@ -46,7 +47,7 @@ documents for different readers. ## Highlights -- **Network segmented by trust, not by function.** Seven VLANs; IoT, media and +- **Network segmented by trust, not by function.** Six VLANs; IoT, media and guest segments are terminal — egress only, no path to anything else, and each carries a tripwire that logs anything which gets past that. Default deny holds everywhere except the trusted workstation segment and the switch LAN, both of diff --git a/docs/adr/0002-vlan-segmentation-strategy.md b/docs/adr/0002-vlan-segmentation-strategy.md index ca20374..6c374ab 100644 --- a/docs/adr/0002-vlan-segmentation-strategy.md +++ b/docs/adr/0002-vlan-segmentation-strategy.md @@ -33,7 +33,7 @@ There were three plausible options: ## Decision -Seven VLANs, assigned by how much a compromise of that segment would cost, with +Six VLANs, assigned by how much a compromise of that segment would cost, with default deny between all of them. | VLAN | Trust | Rationale | diff --git a/docs/adr/0008-place-services-by-data-trust.md b/docs/adr/0008-place-services-by-data-trust.md index 7abf561..6cc6581 100644 --- a/docs/adr/0008-place-services-by-data-trust.md +++ b/docs/adr/0008-place-services-by-data-trust.md @@ -40,7 +40,7 @@ property. ## Decision Services are placed by what their data is worth, across two hosts. **No new -segment is created; the seven VLANs stand.** +segment is created; the six VLANs stand.** **The sensitive tier — Winterfell (99).** A dedicated low-power mini PC hosts Vaultwarden, Immich, Paperless-ngx and Home Assistant, behind Caddy and step-ca diff --git a/docs/network.md b/docs/network.md index f2a0eb3..d9dae77 100644 --- a/docs/network.md +++ b/docs/network.md @@ -1,6 +1,8 @@ # Network -Seven VLANs behind a pfSense firewall, default-deny between segments. Each +Six VLANs behind a pfSense firewall, default-deny between segments — seven +internal networks counting the untagged switch-management LAN, which the table +below records with a dash because it carries no tag. Each section below lists the devices on a segment, how it is wired, and what it is allowed to reach. diff --git a/docs/runbooks/restore-the-firewall.md b/docs/runbooks/restore-the-firewall.md index 74634ea..ef04106 100644 --- a/docs/runbooks/restore-the-firewall.md +++ b/docs/runbooks/restore-the-firewall.md @@ -13,9 +13,9 @@ bootable media, and physical access to the rack > discover during an outage. > See [`hardware.md`](../hardware.md#accessories). -`morpheus` is the single point of failure in this lab. It routes all seven -VLANs, serves DHCP on every tagged interface, and is the only path to the -internet. When it is down the house has no network — not degraded, none. The +`morpheus` is the single point of failure in this lab. It routes six VLANs and +the untagged switch-management LAN — seven internal networks in total — serves +DHCP on every tagged interface, and is the only path to the internet. When it is down the house has no network — not degraded, none. The firewall is also the one device whose loss cannot be worked around from the network, because the network is the thing it provides. diff --git a/scripts/check_docs.py b/scripts/check_docs.py index 11ac94a..46fe6f8 100755 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -127,6 +127,14 @@ COUNT = r"\b(\d+|(?i:" + "|".join(sorted(NUMBER_WORDS, key=len, reverse=True)) + r"))" +# Prose wraps, and a counted claim wraps with it. "It routes all seven\nVLANs" +# in restore-the-firewall.md was invisible to a line-by-line scan for the whole +# life of #209 — the claim was there, the grep that would have found it was not. +# So claims are matched against the whole file and the line is derived from the +# offset. One newline is allowed inside a claim and a blank line is not, so a +# count ending one paragraph cannot bind to a noun starting the next. +WS = r"(?:[ \t]+|[ \t]*\n[ \t]*)" + def number(token: str) -> int: """A counted claim, written either as digits or as a word.""" return int(token) if token.isdigit() else NUMBER_WORDS[token.lower()] @@ -310,6 +318,23 @@ def count_alloy_agents() -> int: ) +def count_vlans() -> int: + """VLANs in docs/network.md's segment table. + + The table is the enumeration; the prose above it was the claim, and they + disagreed five times over (#209). `WAN` and `LAN` carry a dash in the VLAN + column precisely because they are not VLANs — the untagged switch-management + LAN is a real network and a real seventh thing to count, which is why the + wrong number was so durable. Counting the tag column rather than the rows + keeps that distinction. + """ + text = NETWORK_MD.read_text(encoding="utf-8") + rows = tables_under(text, re.compile(r"^#\s+Network$", re.M)) + if not rows: + return 0 + return sum(1 for row in rows[0] if len(row) > 1 and strip_md(row[1]).isdigit()) + + def facts() -> dict: prom_rules = sorted((STACK / "prometheus/rules").glob("*.rules.yaml")) loki_rules = sorted((STACK / "loki/rules").glob("*.rules.yaml")) @@ -330,6 +355,7 @@ def facts() -> dict: "untested_rules": prom - len(tested), "alloy_agents": count_alloy_agents(), "receivers": count_notifying_receivers(), + "vlans": count_vlans(), } @@ -342,42 +368,42 @@ def check_counts(f: dict) -> list[str]: # legitimate prose, so both are accepted — the check still catches a number # that is neither, which is what stale looks like. claims = ( - (rf"{COUNT}\s+alert rules", {f["prometheus_rules"], f["total_rules"]}, + (rf"{COUNT}" + WS + r"alert rules", {f["prometheus_rules"], f["total_rules"]}, "alert rules"), - (rf"{COUNT}\s+rules in total", {f["total_rules"]}, "total rules"), - (rf"{COUNT}\s+rules loaded", {f["prometheus_rules"]}, "rules loaded"), - (rf"{COUNT}\s+rules across", {f["prometheus_rules"]}, "Prometheus rules"), - (rf"{COUNT}\s+metric-based", {f["prometheus_rules"]}, "metric-based rules"), - (rf"{COUNT}\s+log-based", {f["loki_rules"]}, "log-based rules"), + (rf"{COUNT}" + WS + r"rules in total", {f["total_rules"]}, "total rules"), + (rf"{COUNT}" + WS + r"rules loaded", {f["prometheus_rules"]}, "rules loaded"), + (rf"{COUNT}" + WS + r"rules across", {f["prometheus_rules"]}, "Prometheus rules"), + (rf"{COUNT}" + WS + r"metric-based", {f["prometheus_rules"]}, "metric-based rules"), + (rf"{COUNT}" + WS + r"log-based", {f["loki_rules"]}, "log-based rules"), # README says "13 LogQL rules" where observability.md says "log-based". # Same number, different prose; the first phrasing matched nothing. - (rf"{COUNT}\s+LogQL rules", {f["loki_rules"]}, "LogQL rules"), - (rf"{COUNT}\s+(?:provisioned\s+)?dashboards", {f["dashboards"]}, + (rf"{COUNT}" + WS + r"LogQL rules", {f["loki_rules"]}, "LogQL rules"), + (rf"{COUNT}" + WS + r"(?:provisioned\s+)?dashboards", {f["dashboards"]}, "dashboards"), - (rf"{COUNT}\s+panels", {f["panels"]}, "panels"), + (rf"{COUNT}" + WS + r"panels", {f["panels"]}, "panels"), # "39 rules across six files" states two counts. The first was checked # and the second was not, so splitting a rule file could not fail here. - (rf"rules across\s+{COUNT}\s+files", {f["prometheus_rule_files"]}, + (rf"rules across" + WS + COUNT + WS + r"files", {f["prometheus_rule_files"]}, "Prometheus rule files"), # How many rules have a unit test, and how many do not. Both were # unguarded and both were already stale: the sentence read "Coverage is # six rules of 39 ... ContainerHighMemory and Watchdog" while # blackbox.test.yaml had covered three more for weeks. This is the # figure most likely to drift, because it moves whenever a test lands. - (rf"[Cc]overage is\s+{COUNT}\s+rules", {f["tested_rules"]}, + (rf"[Cc]overage is" + WS + COUNT + WS + r"rules", {f["tested_rules"]}, "unit-tested rules"), - (rf"[Oo]ther\s+{COUNT}\s+are still validated", {f["untested_rules"]}, + (rf"[Oo]ther" + WS + COUNT + WS + r"are still validated", {f["untested_rules"]}, "rules without a unit test"), # "Coverage is fifteen rules of 45" states two counts and only the # first was checked, so the denominator could go stale on its own — # the same shape as "39 rules across six files" above, and it did go # stale the same way the moment a rule was added (#81). - (rf"rules of\s+{COUNT}\s+so far", {f["prometheus_rules"]}, + (rf"rules of" + WS + COUNT + WS + r"so far", {f["prometheus_rules"]}, "rules in the coverage denominator"), # Where the agents run is documented, not deployed from here, so the # architecture table is the source and hardware.md's sentence is the # claim. See count_alloy_agents. - (rf"{COUNT}\s+Alloy agents", {f["alloy_agents"]}, "Alloy agents"), + (rf"{COUNT}" + WS + r"Alloy agents", {f["alloy_agents"]}, "Alloy agents"), # The second time a number in observability.md drifted (#72, then #212): # `security` was added and the sentence introducing the routing table # still said three. Both halves of "N receivers, N separate @@ -388,24 +414,31 @@ def check_counts(f: dict) -> list[str]: # Derived from alertmanager.yaml the way the rule counts are derived # from the rule files, so adding a receiver fails here rather than # waiting for someone to reread the paragraph. - (rf"{COUNT}\s+receivers", {f["receivers"]}, "notifying receivers"), - (rf"{COUNT}\s+separate destinations", {f["receivers"]}, + (rf"{COUNT}" + WS + r"receivers", {f["receivers"]}, "notifying receivers"), + (rf"{COUNT}" + WS + r"separate destinations", {f["receivers"]}, "separate destinations"), + # Asserted five times and enumerated zero times (#209). The count is + # the segment table's tag column, so the untagged switch-management LAN + # stays uncounted here and "seven internal networks" stays sayable. + (rf"{COUNT}" + WS + r"VLANs", {f["vlans"]}, "VLANs"), ) problems = [] for rel in PROSE: path = REPO / rel if not path.exists(): continue - for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): - for pattern, expected, label in claims: - for match in re.finditer(pattern, line): - if number(match.group(1)) not in expected: - want = " or ".join(str(v) for v in sorted(expected)) - problems.append( - f"{rel}:{n} claims {match.group(1)} {label}; " - f"the repository has {want}" - ) + text = path.read_text(encoding="utf-8") + for pattern, expected, label in claims: + for match in re.finditer(pattern, text): + if number(match.group(1)) in expected: + continue + n = text.count("\n", 0, match.start()) + 1 + want = " or ".join(str(v) for v in sorted(expected)) + claimed = " ".join(match.group(1).split()) + problems.append( + f"{rel}:{n} claims {claimed} {label}; " + f"the repository has {want}" + ) return problems