Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0002-vlan-segmentation-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0008-place-services-by-data-trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/network.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/runbooks/restore-the-firewall.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
83 changes: 58 additions & 25 deletions scripts/check_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down Expand Up @@ -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"))
Expand All @@ -330,6 +355,7 @@ def facts() -> dict:
"untested_rules": prom - len(tested),
"alloy_agents": count_alloy_agents(),
"receivers": count_notifying_receivers(),
"vlans": count_vlans(),
}


Expand All @@ -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
Expand All @@ -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


Expand Down