diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a40dc06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md — heleosv2 platform + +Multi-tenant web hosting platform: **container-per-site + ZFS-dataset-per-site**, +CLI-driven provisioning, decoupled per-site backup/restore. Single bare-metal +host, Docker Compose per site, no orchestrator. + +> **Read `docs/` first — it is the source of truth.** Start with +> [docs/00-roadmap.md](docs/00-roadmap.md). This file is just the orientation +> map so a fresh session gets up to speed cheaply. + +## Core philosophy +1. **The customer is the boundary** — isolation, backup, restore align on it. +2. **Two levels:** a `customer` owns many `site`s. Isolation/containers/DB/network + are per **site**; grouping (one SFTP login, recursive backup, billing) is per + **customer**. See [docs/03-naming-conventions.md](docs/03-naming-conventions.md). +3. **Containers isolate, they don't secure by themselves** — non-root FPM, + read-only rootfs, per-site networks, egress filtering, least-priv DB users. +4. **Each backup stream matches its change pattern** — web files via ZFS + snapshot/`send`; DB via automysqlbackup + rsync; logs via Loki. Never fold the + DB dump into the web dataset (it bloats every incremental). +5. **CLI/templates before UI.** Prove the platform, then wrap it in a panel. + +## Status +- ✅ Phase 0 — design docs, ADRs, scaffold ([docs/](docs/00-roadmap.md)) +- ✅ Phase 1 — host baseline Ansible ([platform-infra/ansible/](platform-infra/ansible/README.md)) +- 🚧 Phase 2 — platform services (Traefik, MariaDB, Forgejo) in `platform-infra/stacks/` +- ⬜ Phases 3–8 — site templates, provisioning CLI, backup/DR, observability, migration, panel + +## Repo map +| Path | Purpose | +|------|---------| +| `docs/` | Architecture, ADRs, runbooks, conventions (**authoritative**). | +| `platform-infra/ansible/` | Phase 1 host baseline (ZFS, Docker, firewall, SSH). | +| `platform-infra/stacks/` | Phase 2 base compose projects (Traefik/MariaDB/Forgejo). | +| `site-templates/` | Dockerfiles + per-profile compose templates (Phase 3). | +| `deployments/` | Rendered per-site configs `//` (GitOps state). | +| `control-panel/` | Provisioning CLI (Phase 4), panel later. | + +## Naming quick-ref (see docs/03) +- `customer` + `site` ids; `slug = -` (Docker/DB key). +- ZFS: `tank/customers///web`; platform on `tank/platform/*`. +- DB: `db__` + user `u__` on shared MariaDB. +- Networks: shared `proxy` (edge) + `platform` (DB); per-site `_net`. + +## Common commands +```bash +# Phase 1 — host baseline (from platform-infra/ansible, against the VM) +ansible-galaxy collection install -r requirements.yml +ansible-playbook site.yml # tags: base|zfs|docker|firewall|ssh + +# Phase 2 — platform services (on the host, once Phase 1 is applied) +platform-infra/stacks/bootstrap-networks.sh +cd platform-infra/stacks/ && cp .env.example .env && docker compose up -d +``` + +## Working agreements +- **Docs are source of truth.** When a design decision changes, update the + relevant `docs/` file and add/adjust an ADR in `docs/adr/`. +- **Never commit plaintext secrets.** Only `*.enc.*` (SOPS/age) are allowed; + `.env` is git-ignored (`.env.example` is committed). See `.gitignore`. +- **Line endings:** LF for scripts/Dockerfiles/YAML (enforced by `.gitattributes`) + — these run on Linux. +- **Commit per phase/step** with a descriptive message; the repo is the memory. +- Environment is Windows (authoring) → Linux host (runtime). Prefer the Bash tool + for POSIX one-offs. diff --git a/platform-infra/stacks/README.md b/platform-infra/stacks/README.md new file mode 100644 index 0000000..0224264 --- /dev/null +++ b/platform-infra/stacks/README.md @@ -0,0 +1,58 @@ +# Phase 2 — Platform services + +Base Docker Compose projects that every customer site depends on. Each is its +own compose project on the host, using data directories on the ZFS platform +datasets created in Phase 1. + +| Stack | Role | Network(s) | Data | +|-------|------|------------|------| +| `traefik` | Edge router: TLS/ACME, dynamic routing, dashboard. Talks to Docker via a **socket-proxy** (least privilege). | `proxy`, `socketproxy` | `tank/platform/traefik` | +| `mariadb` | Shared database. Per-site DB + user created later by the CLI. Not published to the host. | `platform` | `tank/platform/mariadb` | +| `forgejo` | Git + container registry, git over HTTPS (via Traefik). | `proxy` | `tank/platform/forgejo` | + +## Prerequisites +- Phase 1 applied (ZFS datasets, Docker with the `zfs` driver, firewall). +- DNS A/AAAA records for the platform hostnames (e.g. `traefik.example.com`, + `git.example.com`) pointing at the host, so ACME can issue certificates. + +## Bring-up order +```bash +# 1. Shared networks (once) +./bootstrap-networks.sh + +# 2. Traefik (edge first, so ACME/routing is ready) +cd traefik && cp .env.example .env && $EDITOR .env && docker compose up -d && cd .. + +# 3. Shared MariaDB +cd mariadb && cp .env.example .env && $EDITOR .env && docker compose up -d && cd .. + +# 4. Forgejo +cd forgejo && cp .env.example .env && $EDITOR .env && docker compose up -d && cd .. +``` + +## Secrets +For now each stack reads a git-ignored `.env` (copy from `.env.example`). Real +secrets move to SOPS/age when the provisioning CLI lands (Phase 4). Generate +strong values, e.g.: +```bash +openssl rand -base64 24 # MariaDB root password +htpasswd -nbB admin 'your-password' # Traefik dashboard basic-auth line +``` + +## Verify +```bash +docker compose ls # all three up +curl -I https://traefik.example.com # dashboard (401 = auth working) +docker exec -it mariadb mariadb -uroot -p -e 'SELECT VERSION();' +curl -I https://git.example.com # Forgejo reachable +``` + +## Notes / hardening +- **Traefik ↔ Docker via socket-proxy:** Traefik never mounts `docker.sock` + directly. `tecnativa/docker-socket-proxy` exposes a read-only, minimal subset + on an internal network. This limits blast radius if Traefik is compromised. +- **MariaDB is not published** to the host; it is reachable only by containers on + the internal `platform` network (sites join it to reach the DB). +- **Git over HTTPS only** (SSH disabled) so no extra inbound port is needed — the + Phase 1 firewall only opens 80/443 + admin SSH. Enable Forgejo SSH later only + if you also open its port in the firewall. diff --git a/platform-infra/stacks/bootstrap-networks.sh b/platform-infra/stacks/bootstrap-networks.sh new file mode 100755 index 0000000..a83c1f6 --- /dev/null +++ b/platform-infra/stacks/bootstrap-networks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Create the two shared Docker networks the platform relies on. Idempotent. +# proxy — edge network: Traefik <-> per-site web containers. +# platform — internal network: MariaDB, monitoring. `--internal` = no egress. +# Run once on the host after Phase 1, before bringing up the stacks. +set -euo pipefail + +docker network inspect proxy >/dev/null 2>&1 || docker network create proxy +docker network inspect platform >/dev/null 2>&1 || docker network create --internal platform + +echo "Shared networks ready:" +docker network ls --filter name='^proxy$' --filter name='^platform$' diff --git a/platform-infra/stacks/forgejo/.env.example b/platform-infra/stacks/forgejo/.env.example new file mode 100644 index 0000000..7c3fdb9 --- /dev/null +++ b/platform-infra/stacks/forgejo/.env.example @@ -0,0 +1,6 @@ +# Copy to .env (git-ignored) and fill in. + +# Hostname for Forgejo (git + registry). Needs a DNS record pointing at the host. +# The container registry is served at the same host, e.g.: +# docker login git.example.com +FORGEJO_DOMAIN=git.example.com diff --git a/platform-infra/stacks/forgejo/docker-compose.yml b/platform-infra/stacks/forgejo/docker-compose.yml new file mode 100644 index 0000000..304de76 --- /dev/null +++ b/platform-infra/stacks/forgejo/docker-compose.yml @@ -0,0 +1,33 @@ +# Forgejo: Git hosting + built-in container registry. Git over HTTPS only (SSH +# disabled) so no extra inbound port is needed behind the Phase 1 firewall. +# Uses the built-in SQLite DB (simplest for a single instance); data on ZFS. +services: + forgejo: + image: codeberg.org/forgejo/forgejo:10 + container_name: forgejo + restart: unless-stopped + environment: + USER_UID: "1000" + USER_GID: "1000" + FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN} + FORGEJO__server__ROOT_URL: "https://${FORGEJO_DOMAIN}/" + FORGEJO__server__DISABLE_SSH: "true" + FORGEJO__service__DISABLE_REGISTRATION: "true" + FORGEJO__packages__ENABLED: "true" + volumes: + - /tank/platform/forgejo:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + networks: + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.forgejo.rule=Host(`${FORGEJO_DOMAIN}`)" + - "traefik.http.routers.forgejo.entrypoints=websecure" + - "traefik.http.routers.forgejo.tls.certresolver=le" + - "traefik.http.routers.forgejo.middlewares=security-headers@file" + - "traefik.http.services.forgejo.loadbalancer.server.port=3000" + +networks: + proxy: + external: true diff --git a/platform-infra/stacks/mariadb/.env.example b/platform-infra/stacks/mariadb/.env.example new file mode 100644 index 0000000..8680714 --- /dev/null +++ b/platform-infra/stacks/mariadb/.env.example @@ -0,0 +1,5 @@ +# Copy to .env (git-ignored) and fill in. + +# Root password for the shared MariaDB instance. Generate a strong value: +# openssl rand -base64 24 +MARIADB_ROOT_PASSWORD=replace_with_a_strong_password diff --git a/platform-infra/stacks/mariadb/conf/99-heleos.cnf b/platform-infra/stacks/mariadb/conf/99-heleos.cnf new file mode 100644 index 0000000..ab2107f --- /dev/null +++ b/platform-infra/stacks/mariadb/conf/99-heleos.cnf @@ -0,0 +1,17 @@ +# heleos MariaDB tuning. Adjust innodb_buffer_pool_size to the host's RAM +# (rule of thumb: ~50-70% of RAM dedicated to the DB on a DB-heavy box; less +# here since the host also runs all the sites). +[mysqld] +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +innodb_buffer_pool_size = 512M +innodb_file_per_table = 1 +innodb_flush_log_at_trx_commit = 1 + +max_connections = 200 +skip-name-resolve = 1 + +# Bind to all interfaces *inside the container*; the container is only attached +# to the internal `platform` network, so this is not host-exposed. +bind-address = 0.0.0.0 diff --git a/platform-infra/stacks/mariadb/docker-compose.yml b/platform-infra/stacks/mariadb/docker-compose.yml new file mode 100644 index 0000000..83e92e4 --- /dev/null +++ b/platform-infra/stacks/mariadb/docker-compose.yml @@ -0,0 +1,26 @@ +# Shared MariaDB. Per-site databases + least-privilege users are created later by +# the provisioning CLI (Phase 4). Not published to the host — reachable only by +# containers on the internal `platform` network. +services: + mariadb: + image: mariadb:11.4 + container_name: mariadb + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD} + MARIADB_AUTO_UPGRADE: "1" + volumes: + - /tank/platform/mariadb:/var/lib/mysql + - ./conf/99-heleos.cnf:/etc/mysql/mariadb.conf.d/99-heleos.cnf:ro + networks: + - platform + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 20s + timeout: 5s + retries: 10 + # Root is reachable only from within the container/platform net; no host port. + +networks: + platform: + external: true diff --git a/platform-infra/stacks/traefik/.env.example b/platform-infra/stacks/traefik/.env.example new file mode 100644 index 0000000..66f8ccd --- /dev/null +++ b/platform-infra/stacks/traefik/.env.example @@ -0,0 +1,13 @@ +# Copy to .env (git-ignored) and fill in. + +# Email used for Let's Encrypt registration + expiry notices. +ACME_EMAIL=admin@example.com + +# Hostname for the Traefik dashboard (needs a DNS record pointing at the host). +TRAEFIK_DASHBOARD_HOST=traefik.example.com + +# Basic-auth line(s) for the dashboard. Generate with: +# htpasswd -nbB admin 'your-password' +# NOTE: in a .env file, do NOT double the $ characters (that's only needed when +# putting the value directly in a compose label). +TRAEFIK_DASHBOARD_AUTH=admin:$2y$05$replace_with_real_bcrypt_hash diff --git a/platform-infra/stacks/traefik/docker-compose.yml b/platform-infra/stacks/traefik/docker-compose.yml new file mode 100644 index 0000000..ea4640b --- /dev/null +++ b/platform-infra/stacks/traefik/docker-compose.yml @@ -0,0 +1,88 @@ +# Traefik edge router. Static config is passed as flags (so .env can drive +# ACME email + dashboard host); dynamic config (middlewares, TLS options) is +# loaded from ./dynamic. Docker access goes through a least-privilege socket-proxy. +services: + socketproxy: + image: tecnativa/docker-socket-proxy:0.3.0 + container_name: socketproxy + restart: unless-stopped + environment: + # Grant only what Traefik needs to discover routes; everything else denied. + CONTAINERS: 1 + NETWORKS: 1 + SERVICES: 1 + TASKS: 1 + EVENTS: 1 + PING: 1 + VERSION: 1 + # Explicitly deny the dangerous surfaces. + POST: 0 + EXEC: 0 + IMAGES: 0 + VOLUMES: 0 + INFO: 0 + AUTH: 0 + SECRETS: 0 + SWARM: 0 + SYSTEM: 0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - socketproxy + + traefik: + image: traefik:v3.3 + container_name: traefik + restart: unless-stopped + depends_on: + - socketproxy + command: + - "--global.checknewversion=false" + - "--global.sendanonymoususage=false" + - "--log.level=INFO" + - "--accesslog=true" + # Entry points + global HTTP->HTTPS redirect + - "--entrypoints.web.address=:80" + - "--entrypoints.web.http.redirections.entrypoint.to=websecure" + - "--entrypoints.web.http.redirections.entrypoint.scheme=https" + - "--entrypoints.websecure.address=:443" + # Providers: Docker (via socket-proxy) + file (dynamic dir) + - "--providers.docker=true" + - "--providers.docker.endpoint=tcp://socketproxy:2375" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=proxy" + - "--providers.file.directory=/etc/traefik/dynamic" + - "--providers.file.watch=true" + # Let's Encrypt (HTTP-01 challenge on the web entrypoint) + - "--certificatesresolvers.le.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.le.acme.storage=/acme/acme.json" + - "--certificatesresolvers.le.acme.httpchallenge=true" + - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web" + # Dashboard/API (secured via labels below) + - "--api.dashboard=true" + # Prometheus metrics (scraped in Phase 6) + - "--metrics.prometheus=true" + ports: + - "80:80" + - "443:443" + volumes: + - /tank/platform/traefik/acme:/acme + - ./dynamic:/etc/traefik/dynamic:ro + networks: + - proxy + - socketproxy + labels: + - "traefik.enable=true" + # Dashboard router (HTTPS + basic auth) + - "traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_DASHBOARD_HOST}`)" + - "traefik.http.routers.dashboard.entrypoints=websecure" + - "traefik.http.routers.dashboard.tls.certresolver=le" + - "traefik.http.routers.dashboard.service=api@internal" + - "traefik.http.routers.dashboard.middlewares=dashboard-auth@docker,security-headers@file" + - "traefik.http.middlewares.dashboard-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}" + +networks: + proxy: + external: true + socketproxy: + internal: true diff --git a/platform-infra/stacks/traefik/dynamic/security.yml b/platform-infra/stacks/traefik/dynamic/security.yml new file mode 100644 index 0000000..c328a6b --- /dev/null +++ b/platform-infra/stacks/traefik/dynamic/security.yml @@ -0,0 +1,19 @@ +# Traefik dynamic config: reusable middlewares + TLS options. +# Site routers reference these by name, e.g. `security-headers@file`. +http: + middlewares: + security-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + stsPreload: true + contentTypeNosniff: true + browserXssFilter: true + referrerPolicy: strict-origin-when-cross-origin + frameDeny: true + +tls: + options: + default: + minVersion: VersionTLS12 + sniStrict: true