diff --git a/README.md b/README.md index a97eaf8..dd05135 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Most self-hosted setups have one trust boundary: "you're on the LAN, you're trus This fleet runs **four trust boundaries, each calibrated to who's actually on the other end** — public internet traffic, human browsers on personal devices, machine-provisioned containers, and non-HTTP infra traffic each get a different control, matched to their real risk, not a blanket policy applied everywhere or nowhere. Enforced by **three independent routing authorities** instead of one central router, specifically so a failure or misconfiguration in any single one only ever takes out its own third of the traffic — never all of it at once. It didn't start this way, either: the public proxy used to be its own VM, and the internal service-to-service model used to be a flat, unauthenticated mesh with no boundary at all. Both got replaced deliberately, trading operational simplicity for a smaller blast radius. -![Trust Boundaries and Blast Radius](assets/trust-boundaries-blast-radius.svg) +![Trust Boundaries and Blast Radius](assets/readme-trust-boundaries-blast-radius.svg) The three routing authorities are also split across two separate physical nodes, so a hardware failure is contained the same way a routing failure is — never all three at once. diff --git a/assets/architecture-trust-depth.svg b/assets/architecture-overview-trust-depth.svg similarity index 100% rename from assets/architecture-trust-depth.svg rename to assets/architecture-overview-trust-depth.svg diff --git a/assets/firewall-and-network-docker-bypass.svg b/assets/firewall-and-network-docker-bypass.svg new file mode 100644 index 0000000..72d8bae --- /dev/null +++ b/assets/firewall-and-network-docker-bypass.svg @@ -0,0 +1,4 @@ + + + +
Incoming Traffic
pfSense
Layer 1 — always applies
TARGET HOST — everything below this line is decided by what's running on it
firewalld
Layer 2 — native services only
Native Service
e.g. Alloy, Wazuh agent
Docker-published port
dozens of containerized apps
native path
Docker-published path

Solid orange — every packet, everywhere, always crosses pfSense first
Solid blue — native services get a second gate at the host level
Dashed red — the actual gap: Docker-published ports never reach firewalld
\ No newline at end of file diff --git a/assets/firewall-docker-bypass.svg b/assets/firewall-docker-bypass.svg deleted file mode 100644 index 7226a96..0000000 --- a/assets/firewall-docker-bypass.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Incoming Traffic
pfSense
Layer 1 — always applies
TARGET HOST — everything below this line is decided by what's running on it
firewalld
Layer 2 — native services only
Native Service
e.g. Alloy, Wazuh agent
Docker-published port
dozens of containerized apps
native path
Docker-published path — skips firewalld entirely
Solid orange = every packet, everywhere, always crosses pfSense first. Solid blue = native services get a second gate at the host level. Dashed red = the actual gap: a Docker-published port never reaches firewalld at all — pfSense is the only thing standing between it and incoming traffic.
\ No newline at end of file diff --git a/assets/readme-trust-boundaries-blast-radius.svg b/assets/readme-trust-boundaries-blast-radius.svg new file mode 100644 index 0000000..da8cd61 --- /dev/null +++ b/assets/readme-trust-boundaries-blast-radius.svg @@ -0,0 +1,4 @@ + + + +
pfSense
Edge Firewall + Public HAProxy
PROXMOX NODE A — day-to-day applications
Internal Caddy
app-to-app zone
Docker Apps
LXC host, most of the fleet's stacks
PROXMOX NODE B — trust & sensitive infrastructure
Caddy VM
browser zone, mTLS
Vault + Technitium DNS
trust backbone
public traffic — bypasses everything else
app-to-app traffic
browser traffic
issues certs
issues certs
Solid — traffic actually reaching the apps (public, app-to-app, browser)
Dashed — Vault issues certs to the proxy layer
\ No newline at end of file diff --git a/assets/secret-distribution-flows.svg b/assets/secret-distribution-flows.svg index d2eca35..e0ba6a0 100644 --- a/assets/secret-distribution-flows.svg +++ b/assets/secret-distribution-flows.svg @@ -1,4 +1,4 @@ -
Provisioning Repo
template inventory
Vault Agent Config
on the target host
DATA PLANE — what Vault Agent actually renders
Vault Agent
Certificate Flow
PKI engine, short-lived
App Secret Flow
KV, rendered 0600
Container
reads local disk only
A SEPARATE SYSTEM — no Vault involved
Administrative Secret Flow
ansible-vault encrypted,
committed to git
push, target host pulls
restarts, picks up new template list
decrypted on demand
Orange = control plane (Configuration Distribution Flow) — gates what Vault Agent renders, doesn't touch Vault itself. Purple = data plane (Certificate + App Secret flows) — both run through the same Vault Agent, both consumed locally. Gray = genuinely separate system (Administrative Secret Flow) — no Vault involvement at all, only reachable by whoever holds the ansible-vault passphrase.
\ No newline at end of file +
Provisioning Repo
template inventory
Vault Agent Config
on the target host
DATA PLANE — what Vault Agent actually renders
Vault Agent
Certificate Flow
PKI engine, short-lived
App Secret Flow
KV, rendered 0600
Container
reads local disk only
A SEPARATE SYSTEM — no Vault involved
Administrative Secret Flow
ansible-vault encrypted,
committed to git
push, target host pulls
restarts, picks up new template list
decrypted on demand
Control plane — gates what Vault Agent renders
Data plane — cert + secret flows, consumed locally
Separate system — no Vault involvement at all
\ No newline at end of file diff --git a/assets/secrets-and-pki-lifecycle.svg b/assets/secrets-and-pki-lifecycle.svg new file mode 100644 index 0000000..a96231a --- /dev/null +++ b/assets/secrets-and-pki-lifecycle.svg @@ -0,0 +1,4 @@ + + + +
Vault Agent
runs on the consuming host
Vault
server
Local Disk
rendered file
Application
① authenticate — AppRole (role_id + secret_id)
② short-lived token issued, cached, auto-renewed
③ secret/cert requested + rendered to file (0600, root-owned, gitignored)
④ app reads the local file — never talks to Vault directly
⑤ rotation (async): update once in Vault → picked up on next render, no redeploy
Solid — initial bootstrap sequence, in order
Dashed — periodic rotation, asynchronous, never blocking
\ No newline at end of file diff --git a/assets/secrets-lifecycle.svg b/assets/secrets-lifecycle.svg deleted file mode 100644 index 8d5bc17..0000000 --- a/assets/secrets-lifecycle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Vault Agent
runs on the consuming host
Vault
server
Local Disk
rendered file
Application
① authenticate — AppRole (role_id + secret_id)
② short-lived token issued, cached, auto-renewed
③ secret/cert requested + rendered to file (0600, root-owned, gitignored)
④ app reads the local file — never talks to Vault directly
⑤ rotation (async): update once in Vault → picked up on next render, no redeploy
Solid = the initial bootstrap sequence, in order. Dashed = the separate, periodic rotation cycle — asynchronous, never a blocking dependency for the application.
\ No newline at end of file diff --git a/assets/trust-boundaries-blast-radius.svg b/assets/trust-boundaries-blast-radius.svg deleted file mode 100644 index f532a62..0000000 --- a/assets/trust-boundaries-blast-radius.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
pfSense
Edge Firewall + Public HAProxy
Proxmox Node A
day-to-day applications
Internal Caddy
app-to-app zone
Docker Apps
LXC host, most of the fleet's stacks
Proxmox Node B
trust & sensitive infrastructure
Caddy VM
browser zone, mTLS
Vault + Technitium DNS
trust backbone
public traffic (direct, bypasses everything else)
app-to-app traffic
browser traffic
issues certs
issues certs
\ No newline at end of file diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 1a2cdce..e9253b8 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -18,7 +18,7 @@ The easy answers are both wrong. **One boundary** ("you're on the LAN, you're tr The actual answer here is **four boundaries, each doing a different job**: -![Trust depth: two independent entry paths converge on backend services, which still enforce their own internal boundaries](../assets/architecture-trust-depth.svg) +![Trust depth: two independent entry paths converge on backend services, which still enforce their own internal boundaries](../assets/architecture-overview-trust-depth.svg) | Boundary | Who's on the other end | Control | Why this control, not another | |---|---|---|---| diff --git a/docs/firewall-and-network.md b/docs/firewall-and-network.md index 045d6f0..c9e5dc0 100644 --- a/docs/firewall-and-network.md +++ b/docs/firewall-and-network.md @@ -62,7 +62,7 @@ rule family="ipv4" source address="[automation host]" port port="[this host's SS > [!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. -![Two enforcement layers per packet — pfSense always applies, firewalld only catches native services, Docker-published ports skip straight past it](../assets/firewall-docker-bypass.svg) +![Two enforcement layers per packet — pfSense always applies, firewalld only catches native services, Docker-published ports skip straight past it](../assets/firewall-and-network-docker-bypass.svg) ## Edge firewall: three jobs, one device diff --git a/docs/secrets-and-pki.md b/docs/secrets-and-pki.md index 5a8fdd2..39d45d8 100644 --- a/docs/secrets-and-pki.md +++ b/docs/secrets-and-pki.md @@ -47,7 +47,7 @@ Every Vault Agent reaches Vault through the internal app-to-app proxy zone descr None of these ever call the Vault API themselves — every one of them goes through the same local-render sequence: -![Secret lifecycle: Vault Agent authenticates via AppRole, gets a token, renders the secret to a local file, the application reads that file and never talks to Vault directly — rotation happens asynchronously, updated once in Vault and picked up on the agent's next render](../assets/secrets-lifecycle.svg) +![Secret lifecycle: Vault Agent authenticates via AppRole, gets a token, renders the secret to a local file, the application reads that file and never talks to Vault directly — rotation happens asynchronously, updated once in Vault and picked up on the agent's next render](../assets/secrets-and-pki-lifecycle.svg) **Two authentication flows, not one:** diff --git a/docs/sso-and-identity.md b/docs/sso-and-identity.md index 0b9f9ff..b5eb710 100644 --- a/docs/sso-and-identity.md +++ b/docs/sso-and-identity.md @@ -9,7 +9,7 @@ A homelab with dozens of self-hosted apps means dozens of different auth impleme The proxy layer earns its own keep on top of that, though — it protects apps that don't have a login of their own at all. Not every self-hosted tool ships with real authentication; some are built assuming they'll only ever be reached from an already-trusted network and skip it entirely. Registering a Proxy Provider in front of an app like that puts Authentik's forward-auth check at the reverse-proxy hop, before the request ever reaches the app itself — so an app with zero security of its own still sits behind a real login, enforced by something other than the app. For apps that already have native OIDC, the proxy layer isn't doing the authenticating (the app does its own token exchange) — but for everything else, it's the only auth check that exists, period. -Every app gets the Proxy Provider registration described above — no exceptions, that's the universal baseline. A handful of apps get a second, additional layer on top of it: real native OIDC/SAML support for their own login page, used *alongside* the Proxy Provider rather than replacing it: +Every app gets the Proxy Provider registration described above — that's the universal baseline, with exactly one real exception found by checking live rather than assuming: Proxmox itself has no Proxy Provider at all, confirmed absent from Authentik's own API. It runs on native OIDC only — unlike its sibling PBS, which has both. A handful of other apps get a second, additional layer on top of the Proxy Provider baseline: real native OIDC/SAML support for their own login page, used *alongside* it rather than replacing it. The table below is pulled from Authentik's API directly, not from memory of what was set up — each provider's actual model type, not just its name: | App | Stack | |---|---| @@ -20,10 +20,23 @@ Every app gets the Proxy Provider registration described above — no exceptions | Grafana | [`stacks/platform/grafana-stack/`](../stacks/platform/grafana-stack/) | | Audiobookshelf | [`stacks/media/audiobookshelf/`](../stacks/media/audiobookshelf/) | | Dockhand | [`stacks/platform/dockhand/`](../stacks/platform/dockhand/) | -| Proxmox / PBS | hypervisor-level, not a docker stack | +| Firefly III | [`stacks/productivity/firefly-iii/`](../stacks/productivity/firefly-iii/) | +| Seerr | [`stacks/media/seerr/`](../stacks/media/seerr/) | +| Proxmox / PBS | hypervisor-level, not a docker stack — PBS has both layers, Proxmox has native OIDC only | Every other app runs on the Proxy Provider alone — which, per the point above, is never a weaker path: it's the same forward-auth check either way, just without a second, app-native login layered on top of it. +This is the actual live query against Authentik's API, not a paraphrase of what's assumed to be configured: + +``` +$ curl -s -H "Authorization: Bearer [REDACTED]" \ + "https://authentik.[service-to-service zone]/api/v3/providers/proxy/?page_size=1" | jq '.pagination.count' +96 +$ curl -s -H "Authorization: Bearer [REDACTED]" \ + "https://authentik.[service-to-service zone]/api/v3/providers/proxy/?page_size=200" | jq -r '.results[].name' | grep -E '^proxmox' +[no output — Proxmox has no Proxy Provider at all] +``` + > [!TIP] > **Removal is a real, deliberate step, not an afterthought.** Decommissioning an app means deleting the Application (by slug), then the Proxy Provider, then detaching that provider from the outpost's own provider list, then removing its icon file — in that order. Skipping the outpost-detach step is the easy mistake: the Application and Provider both look gone, but the outpost still references a provider that no longer resolves to anything, and that orphaned reference doesn't surface as an error anywhere obvious. @@ -47,7 +60,7 @@ So an app's OIDC discovery/authority setting always points at the service-to-ser ## Git access: least privilege, not the admin account -Automation should never run with more privilege than it needs. CI and orchestration pushes (the flows described in [`docs/orchestration.md`](orchestration.md)) run through a dedicated, non-admin bot account with scoped write access — never through an administrator identity. Hosts that only need to *read* get separate read-only deploy keys instead of any shared credential. The practical payoff: if an automation credential ever leaks, the blast radius is "can push to specific repos," not "has full admin control of the Git server." +Automation should never run with more privilege than it needs. CI and orchestration pushes (the flows described in [`docs/orchestration.md`](orchestration.md)) run through a dedicated, non-admin bot account with scoped write access — never through an administrator identity. This wasn't the original setup: automated pushes used to run through the personal admin account used for interactive access, the same one with full admin rights over the Git server. That got fixed by standing up a separate account with its own SSH keypair and adding it as a write collaborator on every repo it touches — routine automated commits no longer run under an admin credential at all. Hosts that only need to *read* get separate read-only deploy keys instead of any shared credential. The practical payoff: if an automation credential ever leaks, the blast radius is "can push to specific repos," not "has full admin control of the Git server." ![Gitea repo collaborators: automation-bot with Write access, not an admin account](../assets/gitea-collaborators-automation-bot.png)