Files
public/docs/firewall-and-network.md
T
thesabear 12e69acde7 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.
2026-08-22 08:04:26 +03:00

66 lines
7.0 KiB
Markdown

# 🧱 Firewall & Network Segmentation
> [!NOTE]
> 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["pfSense — Edge Firewall"]
Bogon["Bogon block +<br/>CrowdSec IP-reputation blacklist"]
NonstdSSH["SSH — nonstandard port only"]
PubProxy["HAProxy — public reverse-proxy layer"]
IDS["Suricata inline IDS/IPS"]
end
Edge -->|explicit rules only| LAN
subgraph LAN["Internal Network"]
direction TB
Rule1["DNS · Secrets/PKI · Reverse proxy<br/>Metrics/logs · Security agents<br/>NTP · Admin SSH · File sharing"]
Catchall["Everything else:<br/>default-deny + log"]
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 (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 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
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 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 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["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, 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
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: [`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/)