docs: deepen security/hardening batch to real deep-dive standard

Names real technology throughout (Vault, Authentik, pfSense,
firewalld, Wazuh) instead of staying generic, adds a 'why this
approach vs the alternative' section to every doc, cross-links
heavily between docs instead of treating each system in isolation.
This commit is contained in:
thesabear committed 2026-08-22 08:04:26 +03:00
1 parent 1a605845e3
commit 12e69acde7
8 files changed
+117 -100

No files matched your search

+22 -16
View File
@@ -1,16 +1,16 @@
# 🧱 Firewall & Network Segmentation
> [!NOTE]
> No blanket "anything on the internal network can talk to anything else on it" rule, anywhere. Every real flow is explicit; everything else is logged and dropped.
> pfSense at the edge, firewalld on every individual host — two layers, two different jobs, and a network model with no blanket "anything on the internal network can talk to anything else on it" rule anywhere. Every real flow is explicit; everything else is logged and dropped. This is the enforcement layer underneath the trust boundaries described in [`docs/architecture-overview.md`](architecture-overview.md).
```mermaid
flowchart TD
Internet(["Internet"]) --> Edge
subgraph Edge["Edge Firewall"]
Bogon["Bogon block +<br/>IP-reputation blacklist"]
subgraph Edge["pfSense — Edge Firewall"]
Bogon["Bogon block +<br/>CrowdSec IP-reputation blacklist"]
NonstdSSH["SSH — nonstandard port only"]
PubProxy["Public reverse-proxy layer"]
IDS["Inline IDS/IPS"]
PubProxy["HAProxy — public reverse-proxy layer"]
IDS["Suricata inline IDS/IPS"]
end
Edge -->|explicit rules only| LAN
@@ -21,39 +21,45 @@ flowchart TD
end
```
## Why two layers, not one
A single firewall layer was rejected early: the edge firewall (pfSense) is the fleet's only genuinely public-facing component, so it carries a different threat model — internet-scale scanning and abuse — than internal host-to-host traffic ever will. Collapsing both into one ruleset would mean either the internal segmentation rules get diluted by public-facing noise, or the public-facing rules get relaxed to match internal assumptions. Keeping them separate means each layer's rules can be as strict as that layer's actual threat model demands, independently.
## The one rule that matters most
Every real service-to-service flow — DNS lookups, secrets/PKI traffic, reverse-proxy access, metrics/log shipping, security-agent reporting, NTP, admin SSH, file sharing, and a handful of specific app-to-app integrations — gets its own explicit allow rule. Anything not explicitly listed hits a default-deny-and-log catch-all. **This catch-all is the fleet's actual lateral-movement boundary**, not any single host's own firewall.
Every real service-to-service flow — DNS lookups (see [`docs/dns-naming.md`](dns-naming.md)), secrets/PKI traffic (see [`docs/secrets-and-pki.md`](secrets-and-pki.md)), reverse-proxy access, metrics/log shipping (see [`docs/observability.md`](observability.md)), security-agent reporting (see [`docs/security-monitoring.md`](security-monitoring.md)), NTP, admin SSH, file sharing, and a handful of specific app-to-app integrations — gets its own explicit allow rule on pfSense's LAN interface. Anything not explicitly listed hits a default-deny-and-log catch-all. **This catch-all is the fleet's actual lateral-movement boundary**, not any single host's own firewall — a compromised host can't simply pivot to another host over a port that was never explicitly opened between them.
> [!TIP]
> A few explicit rules are narrower than they look on purpose — inbound access to the secrets platform's own admin API, for instance, is gated to only the two or three services that ever legitimately need it, not the whole fleet.
> A few explicit rules are narrower than they look on purpose — inbound access to Vault's own admin API, for instance, is gated to only the two or three services that ever legitimately need it (the reverse proxy and the DNS server), not the whole fleet. The instinct throughout this ruleset is "name the specific source," not "allow the group and hope."
## Named groups, not raw addresses
Every rule references named groups instead of hardcoded addresses — adding a host to a group is a single membership edit, not a hunt through every rule that needs to reach it. Groups nest: a "whole managed fleet" group is composed of several narrower per-role groups, so a rule against the broad group automatically covers new members of any narrower one.
pfSense aliases (named groups) stand in for hardcoded IP lists throughout — adding a host to a group is a single membership edit, not a hunt through every rule that needs to reach it. Groups nest: a "whole managed fleet" alias is composed of several narrower per-role aliases, so a rule against the broad group automatically covers new members of any narrower one without the rule itself ever changing.
## Per-host firewalls: minimal, not blanket
Each host's own local firewall opens only the specific ports it actually needs. One host goes further: instead of one open port for its most sensitive service, individual source IPs get their own explicit rule.
Each host runs firewalld locally, opening only the specific ports it actually needs — not a blanket "allow everything, block nothing" default. Vault's host goes further than the rest: instead of one open port for its most sensitive service, individual source IPs get their own explicit rich-rule, so a compromised unrelated service on the same network still can't reach it even if it somehow got past pfSense.
> [!WARNING]
> **A real, documented gap**: on Docker hosts, container-published ports bypass the host's own firewall entirely — the container runtime's own network rules are evaluated first. Per-host firewall rules only actually protect the small number of *native*, non-containerized services on that host. The network-level segmentation above (and the edge firewall further out) still applies regardless — defense-in-depth gap, not an open-to-the-world one.
> **A real, documented gap**: on Docker hosts, container-published ports bypass firewalld entirely — Docker's own iptables rules are evaluated before firewalld's zone rules, so firewalld's local restrictions only actually protect the small number of *native*, non-containerized services on that host (things like the observability agent from [`docs/observability.md`](observability.md)), not the dozens of containerized apps in [`stacks/`](../stacks/). The network-level segmentation above still applies regardless — pfSense doesn't care whether a port is opened by firewalld or by Docker's own NAT rules — so this is a defense-in-depth gap specifically at the host layer, not an open-to-the-world one. Worth naming precisely rather than glossing over: firewalld protects less of a Docker host's actual attack surface than it might first appear to.
## Edge firewall: three jobs, one device
```mermaid
flowchart LR
EdgeFW["Edge Firewall"] --> J1["Public ingress<br/>bogon block, IP-reputation feed,<br/>nonstandard SSH port"]
EdgeFW --> J2["Second reverse-proxy layer<br/>for genuinely public apps only,<br/>automated wildcard certs"]
EdgeFW --> J3["Security tooling<br/>inline IDS/IPS, cert renewal"]
EdgeFW["pfSense"] --> J1["Public ingress<br/>bogon block, CrowdSec IP-reputation feed,<br/>nonstandard SSH port"]
EdgeFW --> J2["HAProxy — second reverse-proxy layer<br/>for genuinely public apps only,<br/>automated ACME wildcard certs"]
EdgeFW --> J3["Security tooling<br/>Suricata inline IDS/IPS, cert renewal"]
```
The second reverse-proxy layer here is genuinely separate from the internal two-zone model in [`docs/dns-naming.md`](dns-naming.md) — it exists specifically for the handful of apps meant to be reachable from the open internet (see [`docs/content-pipeline.md`](content-pipeline.md) for one example), and nothing else ever routes through it.
> [!IMPORTANT]
> **Failure impact, thought through in advance**: if the edge firewall goes down, public access and outbound internet both go with it — but every internal service that doesn't depend on WAN reachability keeps running normally. The blast radius is deliberately bounded to "the edge," not "the fleet."
> **Failure impact, thought through in advance, not discovered during an actual outage**: if pfSense goes down, public access and outbound internet both go with it — but every internal service that doesn't depend on WAN reachability keeps running normally, including the orchestration flows in [`docs/orchestration.md`](orchestration.md), which only ever reach internal hosts. The blast radius is deliberately bounded to "the edge," not "the fleet" — a design property worth stating explicitly rather than leaving implicit, since it's exactly the kind of thing that should be known *before* an incident, not discovered during one.
## The hypervisor's own firewall: deliberately unused
The virtualization platform underneath everything has its own built-in firewall capability, left with an empty ruleset on purpose. The edge firewall and each host's own local firewall already cover the two layers that matter — a third overlapping layer would add complexity without meaningfully improving posture. Recognizing when *not* to add a redundant control is as much a design decision as adding one.
Proxmox has its own built-in firewall capability, left with an empty ruleset on purpose. pfSense and firewalld already cover the two layers that matter — public/internal segmentation and per-host minimal exposure — and a third overlapping layer at the hypervisor level would add complexity (a third ruleset to audit, a third place a rule could silently be wrong) without meaningfully improving posture. Recognizing when *not* to add a redundant control is as much a design decision as adding one, and it's the same reasoning that kept the secrets model in [`docs/secrets-and-pki.md`](secrets-and-pki.md) to one trust root instead of two.
---
Related stacks: [`stacks/platform/caddy/`](../stacks/platform/caddy/), [`stacks/platform/caddy-internal/`](../stacks/platform/caddy-internal/), [`stacks/platform/docker-socket-proxy/`](../stacks/platform/docker-socket-proxy/)
Related: [`docs/dns-naming.md`](dns-naming.md), [`docs/architecture-overview.md`](architecture-overview.md), [`docs/security-monitoring.md`](security-monitoring.md) · Stacks: [`stacks/platform/caddy/`](../stacks/platform/caddy/), [`stacks/platform/caddy-internal/`](../stacks/platform/caddy-internal/), [`stacks/platform/docker-socket-proxy/`](../stacks/platform/docker-socket-proxy/)