platform/docs/01-architecture-and-threat-model.md
Bart Van Geyt e5ff798dd4 Refine to nested customer -> site model
Adopt a two-level ownership model: a customer owns many sites, with
isolation/containers/DB/backup at the site level and grouping (one SFTP
login, recursive backup, billing, bulk delete) at the customer level.

- 03-naming-conventions: rewritten for customer/site ids and slug =
  <customer>-<site>; nested ZFS, per-customer SFTP chroot, deployments
  mirror the nesting.
- Propagated paths through docs 01, 04, 05, 06, 07 and deployments/README.
- ADR 0005 retitled/updated to per-site datasets nested under customer.
- architecture-plan.md: note pointing to doc 03 as authoritative on naming.

Clarifies subdomains: same-app subdomains are aliases on one site; a
separate-app subdomain is its own isolated site under the same customer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:37:39 +02:00

6.9 KiB

01 — Architecture & Threat Model

1. Purpose

Rebuild the hosting business as an isolation-first, backup-clean, repeatably-provisioned platform on a single bare-metal host. Serves a mix of WordPress, custom PHP, static HTML, and redirect-only sites.

2. High-level architecture

                          Internet
                             │
                       (80/443, SFTP, admin SSH)
                             │
                    ┌────────▼────────┐
                    │     Traefik     │   edge: TLS termination, ACME,
                    │  (edge router)  │   dynamic label-based routing
                    └───┬─────────┬───┘
                        │         │        shared "proxy" network
        ┌───────────────┘         └───────────────┐
        │ per-site network (acme-shop_net)        │ per-site network (bar-www_net)
   ┌────▼─────┐   ┌──────────┐              ┌─────▼────┐   ┌──────────┐
   │  nginx   │──▶│ php-fpm  │              │  nginx   │──▶│ php-fpm  │
   │acme/shop │   │acme/shop │              │ bar/www  │   │ bar/www  │
   └────┬─────┘   └────┬─────┘              └────┬─────┘   └────┬─────┘
        │ web root vol │                         │ web root vol │
        │              └──────────┐   ┌──────────┘              │
        │                         ▼   ▼                         │
        │                  ┌─────────────────┐                  │
        │                  │  shared MariaDB │  per-site DB +    │
        │                  │  (platform net) │  least-priv user  │
        │                  └─────────────────┘                  │
        ▼                                                        ▼
 tank/customers/acme/shop/web            tank/customers/bar/www/web

The isolation unit is the site (<customer>/<site>). A customer may own several sites, each fully isolated from the others but grouped under the customer for SFTP/backup/billing (see 03).

Platform services (not shown per-site): Forgejo + registry, Prometheus / Grafana / Loki, Uptime-Kuma. All run as their own compose projects on the host.

Request path

  1. DNS points the customer domain at the host.
  2. Traefik terminates TLS (Let's Encrypt) and routes by Host rule (from compose labels) onto the customer's network.
  3. For PHP profiles: nginx serves static assets and proxies .php over FastCGI to that site's php-fpm. For static/redirect: Traefik or a tiny nginx answers directly.
  4. php-fpm talks to shared MariaDB over the platform network using the site's own database + least-privilege credentials.

Why Traefik and nginx (not either/or)

Traefik does not speak FastCGI, so it cannot talk to php-fpm directly. Traefik owns the edge (TLS, routing, ACME); a small per-site nginx bridges HTTP→FastCGI. This is deliberate, not redundancy.

3. Isolation boundaries

Boundary Mechanism Protects against
Process/filesystem Separate containers per site One site reading another's files/processes
Network (east-west) Per-site Docker bridge; only Traefik bridges to proxy Site A reaching Site B's containers (even same customer)
Data at rest ZFS dataset per site (nested under customer), bind-mounted web root Cross-site data access; enables clean per-site restore
Database Per-site DB + least-privilege user on shared instance Site A reading Site B's tables
Privilege php-fpm non-root; read-only rootfs where possible; writable web root only Privilege escalation within a container
Egress Firewall egress filtering (esp. SMTP) Hacked site sending spam / exfiltration

4. Threat model

4.1 Assets

  • Customer website files and databases.
  • The host itself (kernel, Docker daemon, ZFS pool).
  • Platform credentials (DB root, Traefik/ACME, Forgejo, SSH).
  • Backups (onsite snapshots + offsite copies).

4.2 Primary threat actors & scenarios

  1. Compromised WordPress/PHP app (most likely). Attacker gets code execution inside one site's php-fpm container.
  2. Malicious/abusive tenant.
  3. Credential theft (leaked DB or SFTP creds).
  4. External network attacker probing exposed ports.

4.3 What each scenario can and cannot do

Compromised app container (S1):

  • Contained to: that site's files, that site's DB (its creds only), its own network namespace.
  • Blocked from: other customers' files (separate datasets/containers), other DBs (least-privilege user), other customers' networks (no route), spamming (egress SMTP filtered).
  • ⚠️ Residual risk: containers share the host kernel — a kernel or Docker escape breaks isolation. Mitigate with: patched host, non-root FPM, dropped capabilities, no --privileged, read-only rootfs, seccomp defaults; later gVisor and/or Falco runtime detection.

Malicious tenant (S2): same containment as S1, plus resource limits (CPU/memory per compose project) to prevent noisy-neighbour DoS.

Credential theft (S3): blast radius limited to that one site because every site has its own DB user and its own SFTP chroot. Rotate via CLI.

Network attacker (S4): only 80/443, SFTP, and admin SSH are exposed; everything else denied by nftables. Admin SSH key-only + hardened.

4.4 Explicit non-goals / accepted risks

  • Containers are not a hardened sandbox. We accept shared-kernel risk on a single host and mitigate in depth rather than claiming VM-grade isolation.
  • Backups are crash-consistent, not transactionally atomic across the web-file and DB streams (see 06). Acceptable for PHP/WordPress workloads.
  • No HA / multi-host failover in the initial design (single host by choice).

5. Security controls checklist (implemented across phases)

  • Host: nftables default-deny inbound, egress SMTP filtered, SSH key-only.
  • Docker: no --privileged, drop capabilities, read-only rootfs where possible, per-project resource limits, userns considered.
  • Per-customer network isolation; Traefik the only cross-network bridge.
  • php-fpm runs as non-root; web root the only writable mount.
  • Per-site DB users with least privilege; no shared DB accounts.
  • SFTP chrooted per customer.
  • Images scanned (Trivy) and secrets scanned (gitleaks) in CI.
  • Automatic TLS via Traefik/ACME; HSTS.
  • (Later) Falco runtime anomaly detection; gVisor for higher-risk tenants.

See ADRs for the rationale behind each major choice.