diff --git a/docs/dns-naming.md b/docs/dns-naming.md index 25183b6..f749297 100644 --- a/docs/dns-naming.md +++ b/docs/dns-naming.md @@ -1,33 +1,72 @@ # 🌐 DNS Naming Model > [!NOTE] -> Two internal DNS zones, mirroring each other structurally but serving completely different traffic — deliberately never conflated. +> DNS isn't just naming in this fleet — it's the mechanism that decides which trust boundary a request enters. Which zone a hostname resolves in determines whether a request gets mutual-TLS-and-SSO treatment, a lighter-weight trusted-IP path, or the public internet-facing edge entirely. Get the DNS answer wrong and you've effectively picked the wrong security posture for that request. + +| | | +|---|---| +| **Server** | Technitium DNS Server — self-hosted, open-source, chosen over a managed/cloud DNS provider specifically so internal zone data never leaves the LAN | +| **Zones** | Two internal zones, deliberately never merged, plus one public exception | +| **Enforcement** | Every host's resolver is pointed at this server only — direct outbound DNS to anywhere else is blocked at the firewall | +| **Depends on** | Nothing upstream for internal resolution — it's the root of trust for internal naming | +| **Depended on by** | Every system in this repo that resolves a hostname — which is effectively all of them | + +## Why self-hosted DNS at all + +A homelab this size could get away with a shared hosts file or a single flat zone. Two things specifically ruled that out: + +1. **The two-zone trust model needs a real answer to "which front door am I behind."** Splitting traffic into a mutual-TLS-enforced browser zone and a lighter trusted-IP internal zone (see [`docs/firewall-and-network.md`](firewall-and-network.md)) only works if something authoritative decides, per-hostname, which zone a request lands in. A hosts file can't do that at scale across a dozen-plus hosts; a real DNS server with two real zones can. +2. **Every other system needs one source of truth for internal names.** The secrets platform issues a certificate to the DNS server's own admin interface (see below); the orchestration engine resolves every fleet host by name before it can SSH anywhere; the observability pipeline ships DNS query logs as their own dedicated stream. Hand-rolling internal name resolution per-app would mean N inconsistent answers instead of one. + +## The two zones ```mermaid -flowchart LR - Browser["Browser / User"] -->|Zone A| Edge["User-facing reverse proxy
mTLS + SSO enforced"] - Service["Another service"] -->|Zone B| Internal["Internal reverse proxy
trusted-IP allowlist"] - Public["Public internet"] -.bypasses both zones.-> EdgeFW["Edge firewall's own
public reverse proxy"] +flowchart TD + Client["Anything on the LAN"] --> DNS["Technitium DNS"] + DNS --> ZoneA["Zone A: browser-facing"] + DNS --> ZoneB["Zone B: app-to-app"] + DNS -.unresolved.-> Forward["Forwards to the edge firewall
+ a couple of public resolvers"] - Edge --> App["App"] - Internal --> App - EdgeFW --> PublicApp["The one genuinely
public-facing app"] + ZoneA --> EdgeProxy["User-facing reverse proxy
mTLS + SSO enforced"] + ZoneB --> InternalProxy["Internal reverse proxy
trusted-IP allowlist, no mTLS"] ``` -| Zone | Serves | Enforcement | -|---|---|---| -| Browser-facing zone | Users/browsers, via the user-facing reverse proxy | Mutual TLS + SSO enforced at the edge | -| Service-to-service zone | App-to-app traffic (webhooks, API integrations) | Trusted-IP allowlist, no mTLS | +| Zone | Resolves to | Enforcement | Used by | +|---|---|---|---| +| Browser-facing | The user-facing reverse proxy | Mutual TLS (client cert required) + SSO | Anything a human opens in a browser | +| App-to-app | The internal reverse proxy | Trusted-IP allowlist bypass, or SSO forward-auth for anything not on that allowlist | Webhooks, API integrations, service-to-service calls | -Most apps get one hostname per zone, resolving to a different front door depending on who's asking. The two zones aren't perfectly symmetric at any given moment — a handful of apps exist in only one, which is expected: not every stack needs both zones, driven by actual need rather than blanket symmetry. +Most apps that need both human and machine access get **one hostname per zone** — same app, two different front doors, chosen by which zone the caller resolves it in. Not every app needs both: a purely internal API integration might only ever get the app-to-app hostname; an app nobody automates against might only get the browser-facing one. The two zones aren't kept in lockstep by policy — a periodic audit is what actually catches when they drift, not an assumption that they match. > [!TIP] -> **The public exception**: one app is genuinely public-facing, routed directly through the edge firewall's own reverse-proxy package, with zero presence in either internal zone. Apps meant for the public internet skip the internal DNS model entirely rather than getting a redundant third record. +> **The public exception, and why it's a real exception and not a bug**: one app in this fleet ([`docs/content-pipeline.md`](content-pipeline.md)'s social-publishing tool) is genuinely meant to be reached from the open internet, and its own frontend/backend URLs are baked in at configuration time to one specific public hostname. It has **zero presence in either internal zone** — not because it was forgotten, but because a third DNS entry pointing at an app that already has its own public-facing edge route would just be redundant, and the app itself would reject requests arriving under any other hostname anyway (CORS). Apps meant for the public internet skip the internal model entirely rather than being forced into it. -## Caught during a later audit +## DNS as an enforced boundary, not just a convenience > [!IMPORTANT] -> A documentation audit caught real drift: an older note claimed an external DNS forwarder was configured for anything outside the two internal zones. Live verification found no such forwarder actually in place — unresolved queries forward to the edge firewall and a couple of standard public resolvers instead, and internal clients are blocked from using any other resolver directly. The takeaway: documentation drift gets caught by checking live state, not by trusting a prior note was still accurate. +> Every host on the LAN has its resolver pointed at this one internal DNS server — and that's enforced, not just configured. Direct outbound DNS queries to anywhere else (a public resolver, another host acting as its own resolver) are blocked at the firewall level, covered in [`docs/firewall-and-network.md`](firewall-and-network.md). This closes a real bypass: without it, any host could sidestep the internal zones entirely by just asking a public resolver instead, which would quietly undermine the whole two-zone trust model above. DNS enforcement is what makes the rest of this architecture actually load-bearing instead of advisory. + +## Where DNS shows up in every other system + +DNS isn't a standalone concern in this fleet — it's a dependency almost everything else sits on top of: + +| System | How it depends on DNS | +|---|---| +| [Secrets & PKI](secrets-and-pki.md) | The secrets platform issues the DNS server's own admin-UI certificate; separately, every Vault Agent across the fleet has to resolve its target before it can even open a connection | +| [SSO & Identity](sso-and-identity.md) | The OIDC redirect dance depends on the identity provider's hostname resolving correctly on the internal zone, even when the app being logged into lives on the browser-facing one | +| [Firewall & Network Segmentation](firewall-and-network.md) | DNS-bypass prevention (above) is enforced as an explicit firewall rule, not a DNS-server-side restriction | +| [Observability](observability.md) | DNS query logs are their own dedicated ingestion pipeline into the log aggregator, kept separate from general system/container logs | +| [Orchestration](orchestration.md) | Every scheduled maintenance flow resolves its target hosts by name before it can reach them over SSH | +| [Architecture Overview](architecture-overview.md) | DNS resolution is explicitly the mechanism that determines which of the fleet's routing/trust planes a given request enters | + +## Caught during a later audit — and why that matters here + +> [!WARNING] +> A documentation audit caught real drift between what was written down and what was actually true: an older note claimed a specific external DNS forwarder was configured for anything outside the two internal zones. Live verification found no such forwarder actually in place — unresolved queries forward to the edge firewall and a couple of standard public resolvers instead. Small on its own, but it's a useful example of a habit worth having: documentation about a security-relevant system is only as good as the last time it was checked against live state, and a system that determines trust boundaries is exactly the kind of thing worth re-verifying rather than trusting indefinitely. + +## What I'd improve + +Being honest about the current shape, not just the parts that look good: the two zones are audited for drift periodically rather than continuously reconciled, which means a gap can exist undetected between audits. A small script that diffs the two zones and flags asymmetry automatically — rather than relying on someone remembering to check — is a natural next step, and a good example of turning a manual operational habit into an automated one. --- Related stacks: [`stacks/platform/caddy/`](../stacks/platform/caddy/), [`stacks/platform/caddy-internal/`](../stacks/platform/caddy-internal/)