diff --git a/.env.example.prod b/.env.example.prod new file mode 100644 index 000000000..8cf514343 --- /dev/null +++ b/.env.example.prod @@ -0,0 +1,210 @@ +# SourceBans++ production environment template (#1381 deliverable 4). +# +# Copy to `.env` next to `docker-compose.prod.yml`, fill in the +# REQUIRED values, then run: +# +# docker compose -f docker-compose.prod.yml up -d +# +# `.env` is read automatically by `docker compose`. Do NOT commit it +# to git — it carries the panel's JWT signing key and the DB password. +# +# All values below are EXAMPLES. Production values must be unique to +# your deployment. + +# ============================================================================= +# REQUIRED — the stack will refuse to start without these +# ============================================================================= + +# JWT signing key. Persists across container recreations so admin +# sessions survive redeploys. Generate once: +# +# openssl rand -base64 47 +# +# If you regenerate this value, every admin's session cookie becomes +# invalid and they have to log in again. NEVER commit this value. +SB_SECRET_KEY=replace-with-output-of-openssl-rand-base64-47 + +# Database root password (used by mariadb's init step for the first +# boot of the `db` container only — the panel itself uses DB_USER/ +# DB_PASS). Generate with `openssl rand -base64 24`. +DB_ROOT_PASS=replace-with-strong-random-password + +# Database password for the panel user. Generate with +# `openssl rand -base64 24`. Must NOT equal DB_ROOT_PASS. +DB_PASS=replace-with-different-strong-random-password + +# ============================================================================= +# RECOMMENDED — the panel runs without these, but features degrade +# ============================================================================= + +# Steam Web API key. Required for SteamID name resolution and the +# Steam OpenID login path. Get one from: +# https://steamcommunity.com/dev/apikey +# +# The all-zero value below is the dev-stack placeholder; production +# panels must use a real key. +STEAMAPIKEY=00000000000000000000000000000000 + +# Default "From" address for outgoing panel email (password resets, +# protest notifications, etc.). Defaults to noreply@$(hostname) inside +# the container if unset. +SB_EMAIL=noreply@yourdomain.example + +# ============================================================================= +# FIRST-BOOT INSTALL — initial admin account seed +# ============================================================================= +# +# When the entrypoint runs against an empty DB on first boot, it seeds +# an Owner-flagged admin from these four values. Set ALL four — the +# entrypoint skips the seed (and logs a warning) if any is missing, +# leaving you unable to log in until you create an admin by hand. +# +# After the first successful boot, these values are no-ops (the +# entrypoint only runs the seed when the DB is empty). + +# Display name shown in the panel sidebar. +INITIAL_ADMIN_NAME=Admin + +# SteamID2 of the initial admin. Looks like `STEAM_0:1:12345678`. +# Required because the panel's primary identity is the SteamID, not +# the display name. Convert SteamID64 → SteamID2 with +# https://steamid.io or https://steamidfinder.com. +INITIAL_ADMIN_STEAM=STEAM_0:0:0 + +# Email address used for password-reset flows. +INITIAL_ADMIN_EMAIL=admin@yourdomain.example + +# Login password. Minimum 8 characters per the panel's bcrypt floor. +# Change this immediately after first login via the "Your account" +# page; the env var is the SEED, not a persistent source of truth. +INITIAL_ADMIN_PASSWORD=replace-with-strong-temporary-password + +# ============================================================================= +# OPTIONAL — knobs most operators leave at defaults +# ============================================================================= + +# Host port the panel binds to. Default 8080 to avoid colliding with +# anything already on port 80 (e.g. a host nginx). Behind a reverse +# proxy, you'd typically point the proxy at this port. +SBPP_HOST_PORT=8080 + +# Host bind. Empty = bind to all interfaces (the panel is reachable +# from the public internet on SBPP_HOST_PORT). For "reverse-proxy +# only" deployments, set to `127.0.0.1:` (note the trailing colon) +# so only the proxy can talk to the panel. +SBPP_BIND= + +# Image tag to pull from GHCR. Defaults to `latest` (newest +# released stable). Pin to a specific version (e.g. `1.7.0`) for +# reproducible deployments; pin to `main` to ride the bleeding +# edge (NOT recommended for production). +SBPP_IMAGE_TAG=latest + +# Trusted reverse-proxy CIDR ranges. Space-separated. When set, +# Apache trusts X-Forwarded-For / X-Forwarded-Proto from these +# addresses ONLY. Empty default = no proxy trust (so a misconfigured +# deployment doesn't accidentally trust arbitrary clients). +# +# For Caddy / Traefik / nginx on the same compose network, the +# default Docker bridge ranges cover everything: +# +# SBPP_TRUSTED_PROXIES=172.16.0.0/12 10.0.0.0/8 +# +# For Cloudflare's network in front of the panel: +# +# SBPP_TRUSTED_PROXIES=173.245.48.0/20 103.21.244.0/22 ... +# (see https://www.cloudflare.com/ips/ for the current list) +SBPP_TRUSTED_PROXIES= + +# Database name / user / port / prefix / charset. Defaults match +# the dev stack; only change if pointing at an existing DB. +DB_NAME=sourcebans +DB_USER=sourcebans +DB_HOST=db +DB_PORT=3306 +DB_PREFIX=sb +DB_CHARSET=utf8mb4 + +# DATABASE_URL — alternative to the split DB_* vars above. When set, +# the entrypoint parses the URL into DB_HOST/DB_PORT/DB_USER/DB_PASS/ +# DB_NAME and that value wins over the split vars. Useful when the +# DB is provisioned by an app platform (Render / Fly / Railway) +# that emits a single connection URL. +# +# DATABASE_URL=mysql://user:pass@host:port/dbname?charset=utf8mb4 +DATABASE_URL= + +# SBPP_AUTO_INSTALL — when "1" (default), the entrypoint runs the +# first-boot install + migrations on every container start (idempotent +# after the first run). Set to "0" if you provisioned the DB by hand +# (managed RDS, restored backup, etc.) and want the entrypoint to +# only run migrations. +SBPP_AUTO_INSTALL=1 + +# PORT (advanced — do NOT set on a docker-compose deploy) +# +# Inside the container, the entrypoint rewrites Apache's `Listen 80` +# to `Listen $PORT` when this env var is set. The mechanism exists +# so the image runs unmodified on app-platform deploys +# (Render / Fly / Railway / Heroku) that inject `PORT` and expect +# the app to bind to it. +# +# On a docker-compose deploy (this file), `docker-compose.prod.yml` +# hardcodes `${SBPP_HOST_PORT}:80` — host port → container port 80. +# Setting `PORT=8000` here would rewrite the container's listen to +# 8000 BUT compose would still map the host port to the container's +# port 80 (which nothing is listening on), making the panel +# unreachable. SBPP_HOST_PORT above is the right knob for the +# compose host-side; leave PORT unset on compose. +# +# LOW-3 of the #1381 review documented this trap. +# PORT=8080 + +# SBPP_CONFIG_PATH — where the panel reads/writes config.php. Default +# (empty) = the image's writable layer at +# /var/www/html/web/config.php. Set this to a path on a volume or a +# Docker-secret mount when you want config.php to survive container +# image rebuilds (the alternative is setting SB_SECRET_KEY explicitly +# in this file, which the entrypoint will then bake into config.php). +SBPP_CONFIG_PATH= + +# ============================================================================= +# DOCKER SECRETS / *_FILE PATTERN (advanced) +# ============================================================================= +# +# Every secret-shaped env var above (SB_SECRET_KEY, DB_PASS, DB_ROOT_PASS, +# STEAMAPIKEY, INITIAL_ADMIN_PASSWORD) accepts a sibling `_FILE` form +# that points at a file path inside the container. When the sibling is +# set AND the file exists, the entrypoint reads the file's contents +# and uses them as the secret value, overriding the plain env var. +# +# This is the standard Docker Swarm / Kubernetes secret-injection +# idiom. Example: +# +# # docker-compose.prod.override.yml +# services: +# web: +# environment: +# SB_SECRET_KEY_FILE: /run/secrets/sbpp_jwt_key +# DB_PASS_FILE: /run/secrets/sbpp_db_pass +# secrets: +# - sbpp_jwt_key +# - sbpp_db_pass +# secrets: +# sbpp_jwt_key: +# file: ./secrets/jwt_key +# sbpp_db_pass: +# file: ./secrets/db_pass +# +# Plain `docker compose` deploys typically use the env-var form above +# (simpler); the `_FILE` form is for Swarm / k8s where secrets are +# managed by the orchestrator. + +# ============================================================================= +# REVERSE PROXY (only needed if you uncomment the caddy: service) +# ============================================================================= + +# When you uncomment the `caddy:` block in docker-compose.prod.yml, +# set your panel's public domain here. Caddy automatically provisions +# Let's Encrypt TLS for the value. +SBPP_DOMAIN=panel.yourdomain.example diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000..b9bd64818 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,221 @@ +name: Docker image + +# Build + publish the production image to GHCR (#1381 deliverable 3). +# +# Triggers: +# - push to main → :main + :sha- +# - push to *.*.* tag → : + :latest + : + :. +# - workflow_dispatch → manual rerun (same tagging logic; useful +# if a release publish failed midway) +# +# Multi-arch build via docker/build-push-action + buildx + qemu. Both +# linux/amd64 and linux/arm64 are produced and pushed under a single +# manifest list, so `docker pull ghcr.io/sbpp/sourcebans-pp:latest` on +# either an Apple Silicon dev machine or a typical x86_64 VPS resolves +# to the right image without operator awareness. +# +# Signed via Sigstore cosign (keyless / OIDC). The ID-token permission +# below is what enables the keyless signing flow: cosign requests an +# OIDC token from GitHub's issuer, exchanges it with Fulcio for a +# short-lived signing cert, signs the image's manifest, and records +# the signature into Rekor (the public transparency log). Verifiers +# can `cosign verify ghcr.io/sbpp/sourcebans-pp: +# --certificate-identity-regexp=https://github.com/sbpp/sourcebans-pp/... +# --certificate-oidc-issuer=https://token.actions.githubusercontent.com` +# without any pre-shared key. +# +# Path filter scope (PR trigger only — see MED-6 below for the +# push-trigger rationale): +# - docker/Dockerfile.prod, docker/php/prod-*, docker/apache/sbpp-prod.conf, +# docker/php/sb-db.php — every file the runtime image bakes in +# - web/health.php — the file the HEALTHCHECK pings +# - web/init.php / web/init-recovery.php — the SBPP_CONFIG_PATH +# plumbing + install-guard contract the entrypoint relies on +# - web/install/includes/sql/** — schema and seed data the entrypoint +# loads on first-boot install +# - web/includes/Auth/Host.php — the SBPP_TRUSTED_PROXIES gate the +# entrypoint's Apache config feeds (CRIT-4) +# - web/includes/Telemetry/Telemetry.php — the SBPP_SKIP_TELEMETRY +# hook health.php depends on (MED-4) +# - composer.json/lock under web/ — vendor dependency churn +# - .github/workflows/docker-image.yml — touch-the-workflow trigger +# +# MED-6 of the #1381 review: pre-fix, the `push:` block specified +# `branches`, `tags`, AND `paths` at the same level. GitHub Actions +# AND-combines them — a tag push only fires when the underlying +# commit ALSO touches one of the listed paths. Release tags are +# typically attached to commits that already shipped through `main` +# (so the paths filter masked the prod-image surface change), which +# means a release-tag push could silently NOT build the image and +# `:latest` would lag behind `:main` indefinitely. +# +# Fix: drop the `paths` filter from `push:` entirely. Every push to +# `main` AND every release-tag push triggers a full multi-arch build. +# Action minutes for tag pushes are unconditional (correct for a +# release surface); main pushes are rare (PRs aggregate via squash +# merges) so the per-merge cost is acceptable. The `pull_request:` +# block keeps its paths filter — PRs that don't touch the image +# surface still don't rebuild, preserving the per-PR action-minute +# optimisation that originally motivated the path filter. + +on: + push: + branches: + - main + tags: + - '*.*.*' + pull_request: + paths: + - 'docker/Dockerfile.prod' + - 'docker/php/prod-*' + - 'docker/php/sb-db.php' + - 'docker/apache/sbpp-prod.conf' + - 'web/composer.json' + - 'web/composer.lock' + - 'web/health.php' + - 'web/init.php' + - 'web/init-recovery.php' + - 'web/install/includes/sql/**' + - 'web/includes/Auth/Host.php' + - 'web/includes/Telemetry/Telemetry.php' + - '.github/workflows/docker-image.yml' + workflow_dispatch: + +# `packages: write` — push to GHCR. +# `id-token: write` — request an OIDC token for cosign keyless signing. +# `contents: read` — checkout the source tree. +permissions: + contents: read + packages: write + id-token: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: Build + push (${{ github.event_name }}) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + # buildx is the multi-platform driver. qemu provides the cross-arch + # emulation that lets the amd64 GitHub-hosted runner produce an + # arm64 image. The cost is roughly +2x build time on the arm64 + # leg vs native; acceptable for the publish cadence (per-tag + + # main pushes only). + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: linux/arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # GHCR push needs the actor's PAT — for actions/github-token, the + # token's `packages: write` permission is granted by the job-level + # `permissions:` block above. Pull-request builds (which never push) + # skip login. + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # docker/metadata-action computes the tag set from the trigger: + # - main push → :main, :sha- + # - X.Y.Z tag → :X.Y.Z, :X.Y, :X, :latest + # - PR → :pr- (kept locally; not pushed) + # - workflow_dispatch → :main (treated like a main rerun) + # + # The `:latest` tag is gated on `type=semver,pattern={{version}}` + # so PR / main / dispatch builds don't accidentally claim it. + - name: Compute image metadata (tags + labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} + type=sha,prefix=sha-,format=short + labels: | + org.opencontainers.image.title=SourceBans++ + org.opencontainers.image.description=Self-hostable admin / ban / comms management for the Source engine — production image. + org.opencontainers.image.url=https://sbpp.github.io + org.opencontainers.image.source=https://github.com/sbpp/sourcebans-pp + org.opencontainers.image.documentation=https://sbpp.github.io/getting-started/quickstart-docker/ + org.opencontainers.image.licenses=CC-BY-NC-SA-3.0 AND GPL-3.0-or-later + org.opencontainers.image.vendor=SourceBans++ Dev Team + org.opencontainers.image.revision=${{ github.sha }} + + # The build itself. `gha` cache-from / cache-to means the layers + # are persisted in the GitHub Actions cache between runs — buildx + # keys the cache by the Dockerfile + the build context's hash, so + # a Composer-only change won't bust the apt-install layer of the + # builder stage. + # + # `push: ${{ github.event_name != 'pull_request' }}` means PR + # builds verify-build-only (build runs, but the image stays local + # to the runner — no GHCR write). Main / tag / dispatch builds + # publish. + - name: Build + push + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile.prod + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true + + # Cosign keyless signing — runs only when we actually pushed. + # Each tag the manifest carries gets its own signature recorded + # into Rekor. The `cosign sign --yes @` form is the + # documented best-practice (signs the immutable digest, not the + # mutable tag — so a future re-tag doesn't invalidate the + # signature). + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@v3 + with: + cosign-release: 'v2.4.1' + + - name: Sign image with cosign (keyless) + if: github.event_name != 'pull_request' + env: + # NIT-1 of the #1381 review: `COSIGN_EXPERIMENTAL=1` was the + # gate for keyless signing back when it was experimental + # (cosign 1.x). Cosign 2.0+ promoted keyless / OIDC to the + # default behaviour and 2.4.x silently ignores the env var; + # carrying it ~suggests there's still an experimental flag + # in play here when there isn't. + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + # Sign each computed tag against the immutable digest. Iterating + # over $TAGS (newline-separated by docker/metadata-action) keeps + # the loop trivial — no tag-list parsing, no JSON. The + # `@` form is the canonical "sign this exact + # bytes" cosign shape; signing the mutable `:` + # would be valid but bound to the tag name, not the bits. + while IFS= read -r tag; do + [ -z "$tag" ] && continue + image_no_tag="${tag%:*}" + ref="${image_no_tag}@${IMAGE_DIGEST}" + echo "Signing ${ref}" + cosign sign --yes "${ref}" + done <<< "$TAGS" diff --git a/AGENTS.md b/AGENTS.md index 042b002c9..635d93d84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,8 +55,9 @@ code change — never as a follow-up. CI doesn't gate this; it's on you. | Change auth, CSRF, or permissions semantics | `ARCHITECTURE.md` (the relevant subsystem) + `AGENTS.md` (Conventions) if the rule changes | | Add a new permission flag | `web/configs/permissions/web.json`, regen API contract; doc only if the **role** of the flag affects conventions | | Change the local dev stack (Docker, db-init, env vars) | `docker/README.md` first, link from `ARCHITECTURE.md` if it changes the dev mental model | -| Edit user-facing install/quickstart | `docs/src/content/docs/getting-started/quickstart.mdx` (the README is a tiny landing page that links to docs — don't grow it back into a manual) | +| Edit user-facing install/quickstart | `docs/src/content/docs/getting-started/quickstart.mdx` (tarball flow) OR `quickstart-docker.mdx` (Docker flow). Keep the `` arms in `overview.mdx` + `prerequisites.mdx` consistent across the two paths (the README is a tiny landing page that links to docs — don't grow it back into a manual). | | Add or change a wizard step (page handler / View / template / shared helper) | `AGENTS.md` (Install wizard convention block) + the "Edit a step of the install wizard" row in "Where to find what" | +| Touch `docker/Dockerfile.prod`, `docker/php/prod-*`, `docker/apache/sbpp-prod.conf`, `docker-compose.prod.yml`, `.env.example.prod`, `docker/caddy/Caddyfile.example`, or `web/health.php` | `AGENTS.md` (Quality gates: `docker-image.yml` row; "Where to find what": the "Build / extend the production Docker image" + "Deploy / configure the production Docker stack" rows) + `docs/src/content/docs/getting-started/quickstart-docker.mdx` (the operator-facing doc) + `docker/README.md` (dev-vs-prod pointer). The `Plugin build specifics` block in AGENTS.md has a sibling `Prod Docker image specifics` block — keep them in sync with the workflow's path filter / tag mapping / sign step. | | Change a user-facing install / upgrade / troubleshooting flow (PHP or SourceMod version requirements, installer wizard steps, `config.php` behavior, `web/updater/` runner output, plugin `databases.cfg` / `sourcebans.cfg` shape, error messages a self-hoster will see) | The relevant page under `docs/src/content/docs/` (the Starlight site published at sbpp.github.io). | | Add or remove a config knob a self-hoster sets (`config.php` keys, `databases.cfg` fields, plugin convars users tune) | `docs/` page that documents that knob, plus the matching `docs/src/content/docs/updating/*.mdx` page if it's a breaking change between releases | | Ship a new feature with a self-hoster-visible setup step (Discord forwarder, demos, theming, etc.) | New page or section under the right `docs/` group + sidebar entry in `docs/astro.config.mjs` | @@ -180,7 +181,7 @@ services: ## Quality gates -CI runs six gates on every PR. Match them locally before opening one. +CI runs seven gates on every PR. Match them locally before opening one. | Gate | Local | CI workflow | | -------------- | ------------------------------------ | ---------------------- | @@ -190,6 +191,7 @@ CI runs six gates on every PR. Match them locally before opening one. | API contract | `./sbpp.sh composer api-contract` | `api-contract.yml` | | Playwright E2E | `./sbpp.sh e2e` | `e2e.yml` | | Plugin build | `(cd game/addons/sourcemod/scripting && spcomp -i include sbpp_*.sp)` | `plugin-build.yml` | +| Prod Docker image | `docker buildx build --platform linux/amd64,linux/arm64 -f docker/Dockerfile.prod .` | `docker-image.yml` | PHPStan specifics: @@ -336,6 +338,44 @@ Plugin build specifics: spcomp themselves and run the literal command from the table above. Untouched-plugin PRs: trust the gate. +Prod Docker image specifics: + +- Path-filtered to `docker/Dockerfile.prod`, `docker/php/prod-*`, + `docker/apache/sbpp-prod.conf`, `web/composer.{json,lock}`, + `web/health.php`, `web/init.php`, `web/init-recovery.php`, and + the workflow file itself. A web/-only PR that doesn't touch those + files skips the gate (the surface changes constantly; rebuilding + the prod image on every PR would burn action minutes for changes + the next release cycle picks up anyway). Main and tag pushes + always rebuild. +- Multi-arch (linux/amd64 + linux/arm64) via `docker/setup-qemu-action@v3` + + `docker/setup-buildx-action@v3`. The arm64 leg runs under qemu + on the amd64 GitHub-hosted runner — roughly 2x build time vs + native, acceptable for the publish cadence. +- PR builds are **verify-only** — `push: ${{ github.event_name != 'pull_request' }}` + in `build-push-action` runs the build to verify-it-builds without + pushing to GHCR. Main / tag / dispatch pushes publish + sign. +- Tag mapping via `docker/metadata-action@v5`: `main` push → + `:main` + `:sha-`; `X.Y.Z` tag → `:X.Y.Z` + `:X.Y` + `:X` + + `:latest`. `:latest` is gated on `startsWith(github.ref, 'refs/tags/')` + so main pushes can't accidentally claim it. +- Sigstore cosign signs each tag against the immutable digest + (`@`, not the mutable `:`) in keyless + / OIDC mode. The job-level `id-token: write` permission is what + enables the OIDC token request; without it cosign can't get a + Fulcio cert and signing fails closed. Verifiers pin both the + identity (workflow path) and the issuer (GitHub Actions OIDC + endpoint) — see `docs/src/content/docs/getting-started/quickstart-docker.mdx` + for the canonical `cosign verify` command. +- The cosign step is gated on `github.event_name != 'pull_request'` + for the same reason as the push step — PR builds never publish, + so there's nothing to sign. +- No local `./sbpp.sh` wrapper. The dev stack doesn't ship a way + to invoke `docker buildx` from inside the dev container itself + (no Docker-in-Docker), and the prod image build is a host-side + workflow. Contributors who want to verify a local build run the + literal command from the gates table. + ## Conventions ### Namespacing @@ -2565,4 +2605,7 @@ contacting every contributor individually. | Translate raw `PDOException` connect errors into operator-friendly messages on the wizard's database step | `sbpp_install_translate_pdo_error()` in `web/install/includes/helpers.php` (#1335 m4). Pattern-matches the four error codes a non-technical operator is most likely to hit — 1045 (access denied), 2002 (host unreachable), 1049 (unknown database), 1044 (denied for user on database) — and emits a friendlier translation; falls back to the raw message for unrecognised codes so debugging stays possible. Pre-fix the wizard surfaced `SQLSTATE[HY000] [1045] Access denied for user 'sourcebans'@'192.168.96.5' (using password: YES)` verbatim, which is gibberish to non-DBAs and includes the panel-as-seen-by-DB internal IP (minor information disclosure). Regression test: `web/tests/integration/InstallGuardTest.php::testPdoErrorTranslationCoversCommonCodes`. | | Run a stack in parallel with another worktree | Worktree-local `docker-compose.override.yml` (see "Parallel stacks") | | Local dev stack details | `docker/README.md` | +| Build / extend the production Docker image (multi-stage build, hardened runtime, entrypoint state machine, healthcheck) | `docker/Dockerfile.prod` (multi-stage: `builder` runs `composer install --no-dev` against `web/`; `runtime` carries pdo_mysql + intl + zip + mbstring + gmp ONLY — no nodejs / npm / git / dev-prepend) + `docker/php/prod-entrypoint.sh` (pure POSIX shell state machine: `*_FILE` secret resolution → DATABASE_URL parse → defaults → Apache config (PORT + mod_remoteip from `SBPP_TRUSTED_PROXIES`) → wait-for-DB → render config.php (only when missing) → first-boot install (schema + data + seed admin from `INITIAL_ADMIN_*` env vars) → headless updater migrations → strip install/ + updater/ from writable layer → ensure writable cache/templates_c/demos → `exec apache2-foreground`) + `docker/php/prod-php.ini` (production OPcache: `validate_timestamps=0`, `display_errors=Off`, `log_errors=On`, errors → `/dev/stderr`, UTC default, `expose_php=Off`) + `docker/apache/sbpp-prod.conf` (denies dotfiles + vendor/ + configs/ + includes/ + install/ + updater/ + cache/ + templates_c/ + config.php + composer.{json,lock}; `RemoteIPHeader X-Forwarded-For` for the trusted-proxy chain) + `web/health.php` (DB-aware unauthenticated healthcheck — `init.php` bootstraps the panel; `$GLOBALS['PDO']->query('SELECT 1')` returns 200 OK or 503 + plain-text reason; Cache-Control: no-store + X-Robots-Tag: noindex). The production image MUST NOT define `SBPP_DEV_KEEP_INSTALL`; the entrypoint's `strip_install_dirs` step is what makes the panel-runtime guard pass instead (#1381). | +| Deploy / configure the production Docker stack (compose, env vars, reverse-proxy) | `docker-compose.prod.yml` (pulls `ghcr.io/sbpp/sourcebans-pp:${SBPP_IMAGE_TAG:-latest}` — NOT a build context; DB port NOT exposed by default; commented `caddy:` service block for opt-in TLS) + `.env.example.prod` (every supported env var grouped by required / recommended / first-boot / optional / advanced; documents the `*_FILE` Docker-secret pattern and the `SBPP_CONFIG_PATH` Docker-secret-mount pattern; uses `${VAR:?...}` compose syntax for required vars so a fresh-deploy operator who forgot `SB_SECRET_KEY` / `DB_PASS` / `DB_ROOT_PASS` gets a useful container-startup error) + `docker/caddy/Caddyfile.example` (one-line `reverse_proxy web:80` + zstd/gzip encode + static-asset cache headers). Three persistent volumes: `dbdata` + `demos` (MUST persist — DB rows + uploaded ban-evidence); `cache` + `smarty` (CAN be ephemeral — rebuild on demand). Operators run `docker compose -f docker-compose.prod.yml up -d` from a directory carrying both files; upgrades are `docker compose pull && up -d` (image is immutable, entrypoint runs idempotent migrations on every boot, named volumes survive the swap) (#1381). | +| Honour `SBPP_CONFIG_PATH` so config.php can live outside the panel root (Docker-secret mount) | `sbpp_resolve_config_path()` in `web/init-recovery.php` is the single source of truth; `web/init.php` calls it to resolve the require-site. `web/install/already-installed.php`'s `sbpp_install_is_already_installed()` re-implements the env-var read inline (per its self-contained no-Composer docblock) so the wizard-side and runtime-side guards agree on the install-state sentinel path. Pre-#1381 both halves hard-coded the panel-root path; with a Docker-secret-mounted config the runtime would 302-to-/install/ while the wizard would happily start over. Regression tests: `testResolveConfigPathHonorsEnvVar` + `testWizardGuardHonorsConfigPathEnvVar` in `web/tests/integration/InstallGuardTest.php`. | | Change the Contributor License Agreement (text, scope, allowlist) or how the CLA bot gates `web/**` PRs | `CLA.md` (the agreement text — 10 sections, web/-scoped, explicit relicensing right in §3(b)) + `.github/workflows/cla.yml` (the `contributor-assistant/github-action` workflow — paths filter, allowlist, sign-comment text, custom not-signed PR comment) + `CONTRIBUTING.md` (rationale + how-to-sign for contributors). Signatures land on the orphan branch `cla-signatures` under `signatures/cla.json`; the action creates the branch on its first successful run. The maintainer plus all `*[bot]` accounts are allowlisted by default. See "Contributor License Agreement gate" in Conventions. | diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 000000000..23e3172aa --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,205 @@ +# SourceBans++ production compose stack (#1381 deliverable 4). +# +# Designed for self-hosters who want a one-command "spin up a panel that +# survives reboots" deployment. Pulls the published image from GHCR; +# does NOT build from source. Pair with `.env.example.prod` (copy to +# `.env`, edit values, then `docker compose -f docker-compose.prod.yml +# up -d`). +# +# Distinct from `docker-compose.yml` (the dev stack — `./sbpp.sh up`): +# - dev image bind-mounts the worktree, ships admin/admin, exposes +# the DB to host, defines SBPP_DEV_KEEP_INSTALL → install/-presence +# guard skips. ALL of those are dangerous outside a developer's +# laptop. +# - this stack pulls an immutable image; reads admin creds from env +# vars (operator's responsibility); never exposes the DB; the +# entrypoint physically removes install/+updater/ from the +# writable layer so the panel-runtime guard passes without the +# dev escape hatch. +# +# Volumes: +# - dbdata — MariaDB data dir. Must persist (the panel's only +# source of truth for ban / admin / log rows). +# - demos — uploaded ban-evidence demos under web/demos/. Must +# persist (admin-uploaded user content; not regenerable). +# - cache — Smarty compile + sessions under web/cache/. Can be +# ephemeral; will rebuild on first request after wipe +# (operator just gets logged out, no data loss). +# +# config.php is rendered by the entrypoint into the image's writable +# layer at /var/www/html/web/config.php on first boot. To persist +# config.php across container recreations (rather than having the +# entrypoint mint a fresh SB_SECRET_KEY every time the layer is reset), +# set SB_SECRET_KEY in `.env` so the value survives. Volume-mounting +# config.php from outside the container is supported via the +# `SBPP_CONFIG_PATH` env var — see `.env.example.prod` for the +# Docker-secret pattern. + +services: + web: + image: ghcr.io/sbpp/sourcebans-pp:${SBPP_IMAGE_TAG:-latest} + container_name: sbpp-prod-web + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + # Host: by default we bind to all interfaces. Self-hosters who + # front the panel with a reverse proxy (nginx, Caddy, Traefik, + # Cloudflare Tunnel) on the same host should set + # `SBPP_BIND=127.0.0.1:` so only the proxy can reach the panel + # — never the public internet. The trailing colon is intentional; + # it slots in front of the port mapping below. + - "${SBPP_BIND:-}${SBPP_HOST_PORT:-8080}:80" + environment: + # ----- Required: DB connection ----- + # Either set DATABASE_URL OR the split DB_* vars below. The + # entrypoint parses DATABASE_URL into the split vars first; if + # either form is missing a field, the defaults below the URL + # parser kick in. + DATABASE_URL: "${DATABASE_URL:-}" + DB_HOST: "${DB_HOST:-db}" + DB_PORT: "${DB_PORT:-3306}" + DB_NAME: "${DB_NAME:-sourcebans}" + DB_USER: "${DB_USER:-sourcebans}" + DB_PASS: "${DB_PASS:?set DB_PASS in .env or use DB_PASS_FILE for a Docker-secret-mounted file}" + DB_PREFIX: "${DB_PREFIX:-sb}" + DB_CHARSET: "${DB_CHARSET:-utf8mb4}" + + # ----- Required: panel security key ----- + # Persist the JWT signing key across container recreations. + # Generate once with: openssl rand -base64 47 + SB_SECRET_KEY: "${SB_SECRET_KEY:?run 'openssl rand -base64 47' and paste into .env so JWT cookies survive container restarts}" + + # ----- Recommended: Steam Web API + admin email ----- + STEAMAPIKEY: "${STEAMAPIKEY:-}" + SB_EMAIL: "${SB_EMAIL:-}" + + # ----- First-boot install: initial admin ----- + # The entrypoint's first-boot install pass seeds an Owner-flagged + # admin if all four are set; otherwise it skips and prints a + # next-step in the container logs (`docker compose logs web`). + INITIAL_ADMIN_NAME: "${INITIAL_ADMIN_NAME:-}" + INITIAL_ADMIN_STEAM: "${INITIAL_ADMIN_STEAM:-}" + INITIAL_ADMIN_EMAIL: "${INITIAL_ADMIN_EMAIL:-}" + INITIAL_ADMIN_PASSWORD: "${INITIAL_ADMIN_PASSWORD:-}" + + # ----- App-platform / reverse-proxy aware ----- + # PORT — when the app platform injects a port (Render / Fly / + # Heroku-style), the entrypoint rewrites Apache's Listen + + # vhost ports to match. Default 80 for plain Docker. + PORT: "${PORT:-80}" + + # SBPP_TRUSTED_PROXIES — space-separated CIDR list of upstream + # proxies. When set, Apache uses mod_remoteip to rewrite + # REMOTE_ADDR to the original client IP from X-Forwarded-For, + # AND mirrors X-Forwarded-Proto into $_SERVER['HTTPS']. Empty + # by default so plain Docker deploys aren't trust-everyone. + # For Caddy / Traefik / nginx on the same compose network, + # the typical value is "172.16.0.0/12 10.0.0.0/8". + SBPP_TRUSTED_PROXIES: "${SBPP_TRUSTED_PROXIES:-}" + + # SBPP_AUTO_INSTALL — set to "0" to opt OUT of the entrypoint's + # first-boot install pass (e.g. when pointing at a managed DB + # an operator already populated by hand). Default "1". + SBPP_AUTO_INSTALL: "${SBPP_AUTO_INSTALL:-1}" + + # SBPP_CONFIG_PATH — when set, both the entrypoint's + # config.php writer AND the panel runtime's loader read/write + # config from this path. Used to mount config.php from a + # Docker-secret path outside the read-only image layer. + # Default: write into the image's writable layer at + # /var/www/html/web/config.php. + SBPP_CONFIG_PATH: "${SBPP_CONFIG_PATH:-}" + volumes: + # Demos: uploaded ban-evidence demos. MUST persist (admin + # uploads; not in DB, not regenerable from any other source). + - demos:/var/www/html/web/demos + # Cache: Smarty compile + PHP session files. Can be ephemeral — + # the panel rebuilds it on first request. Pinning to a named + # volume is purely a perf optimisation (compiled templates + # survive `docker compose down && up -d` cycles). + - cache:/var/www/html/web/cache + - smarty:/var/www/html/web/templates_c + healthcheck: + # The image's HEALTHCHECK fires every 30s; this block mirrors + # the same shape but with compose-style options. The + # container's HEALTHCHECK is the load-bearing one (Apache's + # status from inside the container); compose just surfaces + # it via `docker compose ps`. + test: ["CMD", "curl", "--fail", "--silent", "--max-time", "8", "http://127.0.0.1/health.php"] + interval: 30s + timeout: 10s + start_period: 120s + retries: 3 + + db: + image: mariadb:11 + container_name: sbpp-prod-db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASS:?set DB_ROOT_PASS in .env (only used for first-boot DB creation; the panel itself uses DB_USER/DB_PASS)}" + MYSQL_DATABASE: "${DB_NAME:-sourcebans}" + MYSQL_USER: "${DB_USER:-sourcebans}" + MYSQL_PASSWORD: "${DB_PASS:?set DB_PASS in .env}" + volumes: + # The DB data dir. THIS IS THE ONLY PATH THAT MUST SURVIVE + # `docker compose down -v` — wiping it loses every ban / admin / + # log row. Compose binds the named volume; back it up with + # `docker compose exec db mariadb-dump -uroot -p...`. + - dbdata:/var/lib/mysql + # NO `ports:` — the DB is on the compose network only. The web + # container reaches it via the service-name DNS (`db:3306`). + # Self-hosters who explicitly want host-port access uncomment + # the block below; the dev compose exposes 3307 for the same + # reason but in prod the surface should default to closed. + # + # ports: + # - "127.0.0.1:3307:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 30 + + # -------- Reverse proxy (commented stub) -------- + # + # Self-hosters who don't already run a reverse proxy on the host + # can uncomment the `caddy` block below to terminate TLS for the + # panel automatically (Let's Encrypt, no manual cert management). + # The Caddyfile is one line — `your.domain.tld { reverse_proxy + # web:80 }`. See `docker/caddy/Caddyfile.example` for the + # canonical shape. + # + # Operators with an existing nginx / Apache / HAProxy / Traefik + # in front of this compose stack do NOT need the Caddy service — + # they keep their existing chain and only add a single + # reverse_proxy directive pointing at the panel's published port. + # + # caddy: + # image: caddy:2 + # container_name: sbpp-prod-caddy + # restart: unless-stopped + # depends_on: + # web: + # condition: service_healthy + # ports: + # - "80:80" + # - "443:443" + # - "443:443/udp" + # volumes: + # - ./docker/caddy/Caddyfile.example:/etc/caddy/Caddyfile:ro + # - caddy-data:/data + # - caddy-config:/config + # environment: + # # The Caddyfile reads $SBPP_DOMAIN to set the site address. + # SBPP_DOMAIN: "${SBPP_DOMAIN:?set SBPP_DOMAIN in .env if you uncomment the caddy service}" + +volumes: + dbdata: + demos: + cache: + smarty: + # Uncomment alongside the caddy: service block above. + # caddy-data: + # caddy-config: diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod new file mode 100644 index 000000000..daf3e6973 --- /dev/null +++ b/docker/Dockerfile.prod @@ -0,0 +1,265 @@ +# SourceBans++ production image (#1381). +# +# Self-hoster-friendly multi-stage build. The dev image (`docker/Dockerfile`) +# bind-mounts the worktree, exposes the DB, ships admin/admin, and defines +# `SBPP_DEV_KEEP_INSTALL` so init.php's install/-presence guard skips — +# none of which is safe in production. The contracts diverge enough that +# the two are deliberately separate Dockerfiles, NOT a single multi-target +# build (the dev path silently inheriting prod's hardening would be a +# subtle dev-feedback regression; the prod path silently inheriting dev's +# OPcache revalidation would be a subtle perf regression). +# +# Stages: +# 1. `builder` — composer:2 + php:8.5-cli; runs +# `composer install --no-dev --optimize-autoloader --prefer-dist` +# against web/. Output: web/ tree with includes/vendor/ +# populated. +# 2. `runtime` — php:8.5-apache; runtime extensions only; copies the +# builder's web/ tree; drops to non-root www-data; +# ships the prod entrypoint that drives the install / +# migrate state machine and removes install/+updater/ +# from the writable layer before handing off to Apache +# (so the panel-runtime guard in web/init-recovery.php +# passes without `SBPP_DEV_KEEP_INSTALL`). +# +# Multi-arch: the workflow builds linux/amd64 + linux/arm64 via buildx; +# the Dockerfile itself stays arch-agnostic (no `--platform=...` pins, +# no arch-specific apt packages). +# +# Non-goals: +# - Bundling Composer / git / nodejs / npm into the runtime stage. +# Self-hosters install via `docker compose pull` then `up -d` — +# no codegen / package-manager round-trip belongs in the runtime +# image. +# - Defining `SBPP_DEV_KEEP_INSTALL`. Production panels MUST NOT +# define it (per AGENTS.md anti-patterns + #1335 C1). The +# entrypoint physically removes install/ + updater/ from the +# writable layer instead. + +# --------------------------------------------------------------------------- +# Stage 1 — builder +# --------------------------------------------------------------------------- +FROM php:8.5-cli AS builder + +ENV DEBIAN_FRONTEND=noninteractive \ + COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_NO_INTERACTION=1 + +# Debian-base apt packages don't pin cleanly across releases; the +# project pins the BASE IMAGE tag instead (`php:8.5-cli`) which gives +# reproducible Debian release / apt snapshot. Pinning individual apt +# packages would force a coupled bump every time the base image rolls +# forward. +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /build +COPY web/ /build/web/ + +# Mirror release.yml's `composer install` flags so the prod image carries +# byte-identical dependencies to the release tarball self-hosters +# unzip-install. Both ship `web/includes/vendor/` populated; both carry +# the optimized autoloader; neither carries dev-only dependencies (PHPStan, +# PHPUnit, etc.). +RUN composer install \ + --working-dir=/build/web \ + --no-dev \ + --optimize-autoloader \ + --no-interaction \ + --prefer-dist \ + --no-progress + +# Sanity-check the bundled vendor tree mirrors release.yml's smoke check. +# A future Composer change that drops Smarty (the highest-value transitive +# dep) would silently produce a broken runtime image; failing the build +# loudly here catches it before publish. +RUN test -f /build/web/includes/vendor/autoload.php \ + && test -f /build/web/includes/vendor/smarty/smarty/src/Smarty.php + +# --------------------------------------------------------------------------- +# Stage 2 — runtime +# --------------------------------------------------------------------------- +FROM php:8.5-apache AS runtime + +ENV DEBIAN_FRONTEND=noninteractive \ + APACHE_DOCUMENT_ROOT=/var/www/html/web + +# Runtime extensions only. The dev image carries `default-mysql-client`, +# `nodejs`, `npm`, `git`, `gettext-base`, `unzip` — all developer-side +# helpers the prod image has no business shipping. `pdo_mysql` is the +# panel's only DB driver. `intl`, `zip`, `mbstring` mirror dev. `gmp` +# is on the spec'd runtime-extension list (#1381 deliverable 1) — it's +# also the soft-dependency `maxmind-db/reader` suggests for large +# integer decoding via the pure-PHP MMDB path, so shipping it keeps +# GeoLite2 country lookups efficient. `gettext-base` is brought in +# solely for `envsubst`, which the entrypoint uses for safe variable +# substitution in the rendered config.php. +# +# HIGH-3 of the #1381 review: pre-fix the runtime image carried +# `default-mysql-client` (~22 MB) so the entrypoint could shell out to +# `mysqladmin ping` for the wait-for-DB loop and `mysql` for the +# schema bootstrap. The spec explicitly excluded it ("No mysql-client +# tooling in the runtime image"). The entrypoint now drives both +# paths through the bundled `docker/php/sb-db.php` PDO helper — same +# pdo_mysql extension every panel request already uses — and the +# apt line shrinks by one package. Don't reintroduce +# `default-mysql-client` here; see `sb-db.php` for the operations it +# replaced. +# (see the builder stage above for the apt-pinning rationale) +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + gettext-base \ + libicu-dev \ + libzip-dev \ + libonig-dev \ + libgmp-dev \ + tzdata \ + && docker-php-ext-install -j"$(nproc)" \ + pdo_mysql \ + intl \ + zip \ + mbstring \ + gmp \ + && a2enmod rewrite headers remoteip setenvif \ + && rm -rf /var/lib/apt/lists/* + +# Production PHP tuning. `display_errors=Off` + `log_errors=On` follows +# 12-factor (errors to stderr; the orchestrator captures them). +# OPcache `validate_timestamps=0` is the production knob — files NEVER +# get re-checked at request time; a `docker compose pull && up -d` +# replaces the container, which is the right invalidation event for +# an immutable image. `max_accelerated_files` is sized at 6k to comfortably +# hold the panel's PHP files (vendor/ alone is ~3500 files; pages/ + +# includes/ + templates_c/ add another ~1500). +COPY docker/php/prod-php.ini /usr/local/etc/php/conf.d/zz-sbpp-prod.ini + +# Apache: serve from /var/www/html/web (the panel lives under web/ in +# the repo, mirrored into the image at /var/www/html/web). +# +# The `${APACHE_DOCUMENT_ROOT}` inside the single-quoted sed expression +# below is INTENTIONAL. The expansion happens at Apache startup time +# (via Apache's own env-var support, not the shell), so we want sed +# to substitute the literal characters `${APACHE_DOCUMENT_ROOT}` into +# the conf files. Letting the shell pre-expand would bake the build- +# time value in and lose the runtime-overridability of the env var. +# hadolint ignore=SC2016 +RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ + /etc/apache2/sites-available/*.conf \ + /etc/apache2/apache2.conf \ + /etc/apache2/conf-available/*.conf \ + && sed -ri -e 's!^User \$\{APACHE_RUN_USER\}!User www-data!' \ + -e 's!^Group \$\{APACHE_RUN_GROUP\}!Group www-data!' \ + /etc/apache2/apache2.conf + +# Ship a minimal default vhost that disables directory listings, denies +# access to dotfiles, and protects the panel-internal directories that +# self-hosters historically had to wire up themselves via .htaccess. +COPY docker/apache/sbpp-prod.conf /etc/apache2/conf-available/zz-sbpp-prod.conf +RUN a2enconf zz-sbpp-prod + +# Server-Tokens trim — don't leak the Apache version + module list in +# error pages or the Server: header. +RUN { \ + echo 'ServerTokens Prod'; \ + echo 'ServerSignature Off'; \ + echo 'TraceEnable Off'; \ + } > /etc/apache2/conf-available/zz-sbpp-tokens.conf \ + && a2enconf zz-sbpp-tokens + +# Copy the panel tree from the builder stage (vendor/ already populated). +# We chown to www-data:www-data so the runtime user can write to cache/ +# templates_c/ + the demos/ volume mount target. +COPY --from=builder --chown=www-data:www-data /build/web/ /var/www/html/web/ + +# Healthcheck endpoint (#1381 deliverable 5b). Bypasses session / chrome / +# routing — runs init.php for the DB connection and returns 200 OK on a +# successful `SELECT 1`, 503 with a one-line stderr log otherwise. +COPY --chown=www-data:www-data web/health.php /var/www/html/web/health.php + +# Pre-create the writable directories the runtime needs. cache/ + +# templates_c/ are Smarty + sessions; demos/ is the only must-persist +# user-data dir (uploaded ban-evidence demos). The compose file binds +# named volumes over each path; pre-creating with the right ownership +# means the volume's first-mount inherits 0775 + www-data ownership +# instead of the orphan root-owned default Docker would otherwise use. +RUN mkdir -p \ + /var/www/html/web/cache \ + /var/www/html/web/cache/sessions \ + /var/www/html/web/templates_c \ + /var/www/html/web/demos \ + && chown -R www-data:www-data \ + /var/www/html/web/cache \ + /var/www/html/web/templates_c \ + /var/www/html/web/demos \ + && chmod -R 0775 \ + /var/www/html/web/cache \ + /var/www/html/web/templates_c \ + /var/www/html/web/demos + +# Bundled PHP CLI helper the entrypoint shells out to for DB ops +# (HIGH-3: replaces the previous `mysqladmin ping` + `mysql < +# schema.sql` calls that drove the `default-mysql-client` apt +# dependency above). Lives under /usr/local/lib/sbpp/ — outside +# /var/www/html/web/ — so it stays reachable after the entrypoint +# `rm -rf`s install/ + updater/ in step 7. +COPY docker/php/sb-db.php /usr/local/lib/sbpp/sb-db.php + +# Production entrypoint: drives the install + migrate state machine, +# strips install/ + updater/ from the writable layer, then execs +# apache2-foreground. See docker/php/prod-entrypoint.sh for the full +# state-machine docblock. +COPY docker/php/prod-entrypoint.sh /usr/local/bin/prod-entrypoint +RUN chmod +x /usr/local/bin/prod-entrypoint + +# Apache binds 0.0.0.0:80 by default; the entrypoint rewrites this to +# `$PORT` when the env var is set (Render / Fly / Heroku-style platforms +# inject PORT). EXPOSE is documentation-only, but we keep it at the +# default 80 to match the unconfigured-PORT case. +EXPOSE 80 + +# DB-aware healthcheck — Apache + PHP-FPM are useless to the orchestrator +# if the panel can't reach the DB. The endpoint itself bootstraps init.php +# (which connects via PDO) and returns the DB state. +# +# Generous interval/timeout because the panel's first-boot install can +# briefly stall the entrypoint on a slow managed DB (RDS / DigitalOcean +# Managed DB take 3-5s on cold start). `start_period` is wide enough to +# cover the first-boot install + migrate pass. +HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \ + CMD curl --fail --silent --show-error --max-time 8 \ + http://127.0.0.1:${PORT:-80}/health.php >/dev/null \ + || exit 1 + +# `curl` is needed by HEALTHCHECK above; install lazily so apt cache stays +# in the previous RUN's layer. +# (see the builder-stage apt block for the apt-pinning rationale) +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +# OCI labels — populate GHCR's UI panel + give `docker inspect` a useful +# provenance trail. The release workflow overrides `revision` + `version` +# at build time via `--label`; the static labels here are the defaults +# for non-release builds (e.g. `:main` and `:sha-`). +LABEL org.opencontainers.image.title="SourceBans++" \ + org.opencontainers.image.description="Self-hostable admin / ban / comms management for the Source engine — production image." \ + org.opencontainers.image.url="https://sbpp.github.io" \ + org.opencontainers.image.source="https://github.com/sbpp/sourcebans-pp" \ + org.opencontainers.image.documentation="https://sbpp.github.io/getting-started/quickstart-docker/" \ + org.opencontainers.image.licenses="CC-BY-NC-SA-3.0 AND GPL-3.0-or-later" \ + org.opencontainers.image.vendor="SourceBans++ Dev Team" + +WORKDIR /var/www/html/web + +ENTRYPOINT ["prod-entrypoint"] +CMD ["apache2-foreground"] diff --git a/docker/README.md b/docker/README.md index b7e75c4b8..eb16b51ec 100644 --- a/docker/README.md +++ b/docker/README.md @@ -5,6 +5,17 @@ PHP/Apache, MariaDB, Adminer, and Mailpit, seeds the database, and creates a ready-to-use admin login. Source under `web/` is bind-mounted, so edits show up on the next request — no rebuilds needed. +> **Dev or prod?** This page documents the **development** stack. For +> the **production** install path (immutable image pulled from GHCR, +> hardened entrypoint, no install wizard, multi-arch / cosign-signed) +> see the +> [Docker quickstart](https://sbpp.github.io/getting-started/quickstart-docker/) +> docs page plus `docker-compose.prod.yml` + `.env.example.prod` at the +> repo root. The two stacks are deliberately separate — the dev stack +> ships `admin/admin`, bind-mounts the worktree, defines +> `SBPP_DEV_KEEP_INSTALL` (a panel-takeover guard bypass), and exposes +> the DB to host. None of that is safe outside a developer's laptop. + ## Prerequisites - Docker 24+ with the Compose plugin (`docker compose`, not `docker-compose`) diff --git a/docker/apache/sbpp-prod.conf b/docker/apache/sbpp-prod.conf new file mode 100644 index 000000000..a840ecb44 --- /dev/null +++ b/docker/apache/sbpp-prod.conf @@ -0,0 +1,84 @@ +# SourceBans++ production Apache hardening (#1381). +# +# Loaded as /etc/apache2/conf-available/zz-sbpp-prod.conf via a2enconf +# in docker/Dockerfile.prod. The `zz-` prefix wins specificity over +# vendor defaults shipped by php:8.5-apache. +# +# Scope: +# - Forbid directory listings everywhere under DocumentRoot. +# - Deny access to dotfiles (.git, .env, .htaccess, ...). +# - Deny access to internal directories the panel reads but no +# external user should hit (vendor/, cache/, install/, updater/, +# configs/, includes/). +# - Document-root scoped, never global. A panel installed under a +# subpath is unsupported on this image (operators should use the +# reverse-proxy / Caddy stub for path-based deploys; the image +# assumes the panel owns the root). + + + Options -Indexes -MultiViews +FollowSymLinks + AllowOverride None + Require all granted + + +# Block dotfiles globally — `.env`, `.git/*`, `.htaccess`, `.DS_Store`. +# Some self-hosters mount their `.env` into the panel root by accident +# (or carry a stray `.git/` from `git clone` rather than the release +# zip); this is the belt-and-suspenders so leaks like that aren't +# fetchable. + + Require all denied + + +# Internal directories — the panel reads these via PHP (`require_once`); +# nothing legitimate fetches a URL inside them. Pre-image, self-hosters +# either knew to copy the release tarball's `.htaccess` files (a +# trap — easy to miss on a fresh upload) or relied on `vendor/` / +# `configs/` not being indexed by accident. Both fragile; codify here. + + Require all denied + + +# `install/` and `updater/` are removed from the writable layer by the +# entrypoint after first-boot install / pending migrations complete +# (web/init-recovery.php's guard refuses to boot if either is on disk +# and `SBPP_DEV_KEEP_INSTALL` is not defined; the prod image MUST NOT +# define that constant). The deny-rules below are belt-and-suspenders +# for the brief window during boot when the directories still exist +# and Apache is also up (it isn't — entrypoint's `exec +# apache2-foreground` runs AFTER the rm -rf), and for any +# bind-mount-overlay / volume-overlay shape that re-exposes the +# directories. + + Require all denied + + +# `cache/` is the Smarty compile + sessions dir. Nothing under it is +# meant to be served — Smarty templates compile to `.tpl.php` files +# which would expose template internals if accidentally indexed. +# The compile-dir contents include serialised session data under +# `cache/sessions/`. + + Require all denied + + +# `config.php` is the install-state sentinel. It carries DB credentials, +# the JWT secret key, and the Steam API key. Never serve it. + + Require all denied + + +# `composer.json` / `composer.lock` are checked-in but should never be +# fetched (they reveal exact dependency versions for vulnerability +# scanning). Same for the project's own helpers (`init-recovery.php`, +# `phpstan.neon`, etc.) at the panel root. + + Require all denied + + +# Forward the actual client IP via mod_remoteip. By default this is a +# no-op — the per-deploy trust list is configured by the entrypoint +# from the SBPP_TRUSTED_PROXIES env var. The directive is loaded here +# so the entrypoint's per-deploy conf only has to add `RemoteIPInternalProxy` +# lines (the header name is project-wide constant). +RemoteIPHeader X-Forwarded-For diff --git a/docker/caddy/Caddyfile.example b/docker/caddy/Caddyfile.example new file mode 100644 index 000000000..5208aee78 --- /dev/null +++ b/docker/caddy/Caddyfile.example @@ -0,0 +1,44 @@ +# SourceBans++ Caddyfile example (#1381 deliverable 4). +# +# Minimal "panel behind Caddy with automatic Let's Encrypt TLS" config. +# Pair with the commented `caddy:` service block in +# `docker-compose.prod.yml`. +# +# Usage: +# 1. Point your domain's A/AAAA records at the host running this +# compose stack. +# 2. Set SBPP_DOMAIN in `.env` to that domain. +# 3. Set SBPP_TRUSTED_PROXIES in `.env` so the panel trusts Caddy's +# forwarded headers: +# +# SBPP_TRUSTED_PROXIES=172.16.0.0/12 10.0.0.0/8 +# +# 4. Uncomment the `caddy:` service block in +# docker-compose.prod.yml. +# 5. `docker compose -f docker-compose.prod.yml up -d` +# +# Caddy provisions the TLS cert automatically on first request. +# +# {$SBPP_DOMAIN} is substituted at Caddy startup from the env var. + +{$SBPP_DOMAIN} { + # Forward to the panel container on the compose network. + # `web` is the service name from docker-compose.prod.yml; Caddy + # resolves it via Docker's embedded DNS. + reverse_proxy web:80 + + # Caddy automatically sets X-Forwarded-For + X-Forwarded-Proto + + # X-Forwarded-Host on reverse_proxy directives. The panel + # entrypoint configures Apache's mod_remoteip to honour them + # when SBPP_TRUSTED_PROXIES covers the Docker bridge range. + + # Gzip / zstd response compression. + encode zstd gzip + + # Server-side cache headers for the panel's static assets + # (theme.css, theme.js, lucide.min.js, *.png). The panel itself + # emits sensible Cache-Control on dynamic responses; this rule + # adds a year-long max-age for the static surface. + @static path *.css *.js *.png *.jpg *.jpeg *.gif *.webp *.svg *.ico *.woff *.woff2 + header @static Cache-Control "public, max-age=31536000, immutable" +} diff --git a/docker/php/prod-entrypoint.sh b/docker/php/prod-entrypoint.sh new file mode 100755 index 000000000..6e2f1e967 --- /dev/null +++ b/docker/php/prod-entrypoint.sh @@ -0,0 +1,937 @@ +#!/bin/sh +# SourceBans++ production entrypoint (#1381 deliverable 2). +# +# Drives the install + migrate state machine, strips install/+updater/ +# from the writable layer, configures Apache for the deployment +# (PORT rewrite, mod_remoteip), then execs apache2-foreground. Idempotent +# on every container start. +# +# State machine (see "step:" log prefixes for each): +# 1. Resolve config (PORT, *_FILE secrets, DATABASE_URL). +# 2. Write Apache vhost overrides for $PORT + trusted proxies. +# 3. Wait for the DB to accept connections. +# 4. Render config.php from environment if it's missing or empty. +# 5. First-boot install: pipe struc.sql + data.sql + seed initial admin. +# 6. Run pending updater migrations against config.version. +# 7. Strip install/ + updater/ from the writable layer (so the +# panel-runtime guard in web/init-recovery.php passes — +# production MUST NOT define SBPP_DEV_KEEP_INSTALL). +# 8. Ensure cache/ + templates_c/ + demos/ are writable by www-data. +# 9. exec apache2-foreground. +# +# Pure POSIX shell — no bash-isms. Some self-hosters base their image +# on Alpine for size; staying portable means a future busybox-only image +# variant is a one-line FROM swap rather than a full rewrite. +# +# Why we drive install programmatically instead of pointing operators +# at the wizard: +# - The wizard expects an interactive operator. App-platform deploys +# (DigitalOcean / Railway / Render / Fly) inject env vars via the +# deploy form and then run the container; there is no operator at +# the keyboard to walk a 6-step wizard. +# - The wizard writes config.php; we want config.php to come from +# the deployment's environment / secrets manager. Driving install +# from the entrypoint keeps the env-vars path the single source +# of truth. +# - The wizard requires install/ to stay on disk through the user's +# session. We need install/ gone before Apache binds — the +# post-#1335 panel-runtime guard refuses to boot otherwise. + +# `pipefail` ensures a failure midway through a `foo | bar` pipeline +# propagates the failure rather than being masked by the trailing +# command's exit (LOW-1 of the #1381 review). The first_boot_install +# pass pipes a `sed | run_sql` heredoc; without pipefail, a sed +# failure (e.g. corrupted struc.sql) would be silently masked by the +# run_sql success on an empty stream, and we'd boot against an +# half-loaded schema. +# +# `pipefail` is a POSIX-2024 addition that the surrounding image's +# shell (Debian dash 0.5.11+ and Alpine busybox ash 1.31+) both +# support — the "no bash-isms" promise above still holds in +# practice. shellcheck doesn't know that yet, hence the disable. +# shellcheck disable=SC3040 +set -euo pipefail + +WEB_ROOT="/var/www/html/web" +LOG_PREFIX="[prod-entrypoint]" +SBPP_AUTO_INSTALL="${SBPP_AUTO_INSTALL:-1}" + +# Path to the bundled PHP helpers (sb-db.php). Ships under +# /usr/local/lib/sbpp/ from the Dockerfile so the entrypoint can +# reach them without taking a dependency on the panel's WEB_ROOT +# (which gets a `rm -rf install/+updater/` halfway through boot). +SBPP_LIB_DIR="${SBPP_LIB_DIR:-/usr/local/lib/sbpp}" + +log() { printf '%s %s\n' "$LOG_PREFIX" "$*" >&2; } +warn() { printf '%s WARN: %s\n' "$LOG_PREFIX" "$*" >&2; } +die() { printf '%s ERROR: %s\n' "$LOG_PREFIX" "$*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# *_FILE secret resolution (Docker Swarm + k8s + many app platforms) +# --------------------------------------------------------------------------- +# +# For each secret env var (DB_PASS, SB_SECRET_KEY, STEAMAPIKEY, …), +# prefer the value of `${NAME}_FILE` if set AND the path exists. This +# matches the canonical Docker secret pattern: the orchestrator mounts +# the secret at /run/secrets/, the operator sets `DB_PASS_FILE=/run/secrets/db_pass` +# in the service env, and the entrypoint reads the file's contents. +# +# When `_FILE` is unset (or set but empty / missing-on-disk), the +# helper leaves the existing `` value alone — so plain env vars +# work as a fallback for self-hosters who don't use a secrets manager. +resolve_file_secret() { + name="$1" + file_var="${name}_FILE" + eval "file_path=\${$file_var:-}" + if [ -n "$file_path" ] && [ -f "$file_path" ]; then + # Read the file, then strip trailing CR/LF from the LAST line + # only. Operators routinely create secret files with + # `echo "value" > /run/secrets/foo` which appends a `\n`, and + # Docker Desktop on Windows often serialises secrets with CRLF + # line endings — neither byte belongs in the secret. NIT-2 of + # the #1381 review. We use `printf '%s'` (no newline) around + # `tr` instead of stripping in the eval below so a secret that + # legitimately ends in whitespace on an inner line survives. + eval "$name=\$(printf '%s' \"\$(cat \"\$file_path\")\" | tr -d '\r\n')" + # MED-1 of the #1381 review: an empty *_FILE points at the + # wrong path (typo, missing volume mount, secret not synced + # yet) and silently leaves the var at its old / default value + # — which for DB_PASS is "" and silently weakens the panel's + # auth surface. Refuse to start instead. + eval "_value=\${$name}" + if [ -z "$_value" ]; then + die "${file_var}=${file_path} resolved to an empty value (file missing trailing newline OK; truly empty file is not). Fix the secret payload or unset ${file_var} to fall back to ${name}." + fi + # `export "$name"` works (POSIX `export` accepts an expanded + # variable name as an arg), but shellcheck flags it as SC2163 + # because the static analysis can't see the deferred expansion. + # Wrapping the value in `${var?}` is the documented silencing + # shape and also gates against `$name` being unset, so the + # behaviour is strictly tighter than the bare form. + export "${name?refusing to export an unnamed secret}" + fi +} + +resolve_secrets() { + for name in \ + DB_HOST DB_PORT DB_NAME DB_USER DB_PASS DB_PREFIX DB_CHARSET \ + STEAMAPIKEY SB_EMAIL SB_SECRET_KEY \ + INITIAL_ADMIN_NAME INITIAL_ADMIN_STEAM INITIAL_ADMIN_EMAIL INITIAL_ADMIN_PASSWORD; do + resolve_file_secret "$name" + done +} + +# --------------------------------------------------------------------------- +# DATABASE_URL parse (Render / Heroku / Railway-style) +# --------------------------------------------------------------------------- +# +# When DATABASE_URL is set, parse it into the split DB_* vars BEFORE +# defaulting / config-rendering happens. Shape: +# +# mysql://user:pass@host:port/dbname?charset=utf8mb4 +# +# Only fields that the URL provides are overridden — so a self-hoster +# can mix a DATABASE_URL with explicit `DB_PREFIX=sb_prod` to override +# just the prefix while letting the URL carry the rest. +# +# Pure POSIX sed; no `awk -F` shape because the password may contain +# `:` / `@` characters that field-splitting would mangle. The regex +# below is forgiving — uses `[^/]` rather than strict scheme matching +# so a `mysql+pdo://...` (Symfony-style) shape is accepted too. +parse_database_url() { + if [ -z "${DATABASE_URL:-}" ]; then + return + fi + log "step 1: parsing DATABASE_URL" + + # Strip the scheme ("mysql://", "mysql+pdo://", etc.) — everything + # before the first `//`. + rest="${DATABASE_URL#*://}" + + # Split off the path (`/dbname?...`) from the authority + # (`user:pass@host:port`). + case "$rest" in + */*) authority="${rest%%/*}"; path_and_query="/${rest#*/}" ;; + *) authority="$rest"; path_and_query="" ;; + esac + + # Split authority into userinfo + host:port. The `@` separator + # lives between them. If there's no `@`, the whole authority is + # host[:port] and there's no userinfo. + case "$authority" in + *@*) + userinfo="${authority%@*}" + hostport="${authority##*@}" + ;; + *) + userinfo="" + hostport="$authority" + ;; + esac + + # Split userinfo into user + pass on the FIRST `:` (a password + # containing `:` is fine because we're greedy on the LEFT not + # the right). + if [ -n "$userinfo" ]; then + case "$userinfo" in + *:*) + _DB_USER="${userinfo%%:*}" + _DB_PASS="${userinfo#*:}" + ;; + *) + _DB_USER="$userinfo" + _DB_PASS="" + ;; + esac + # URL-decode percent-escapes (a password with `@` arrives as + # `%40`, a space as `%20`, etc.). The previous shape was + # `printf '%b' "$(echo "$value" | sed 's/%/\\x/g')"` — that's + # a no-op on Debian's dash (CRIT-1 of the #1381 review): only + # bash's builtin `printf` expands `\xHH` sequences. Every DB + # password with URL-reserved chars (`@`, `:`, `/`, `?`, `#`, + # `&`, `%`, `+`, space) was silently mangled — the panel + # ended up authenticating against `p\x40ssword` instead of + # `p@ssword`. + # + # PHP is already in the runtime image; `urldecode()` is its + # one-liner. The `--` separator tells PHP's option parser + # that the values that follow are positional argv, not PHP + # options — important when the user/pass starts with a dash. + # shellcheck disable=SC2016 + # ^ single-quote intentional — `$argv[1]` is PHP's argv, + # not a shell variable; double-quoting would let the shell + # try (and fail) to expand it before php sees the string. + DB_USER="$(php -r 'echo urldecode((string) ($argv[1] ?? ""));' -- "$_DB_USER")" + # shellcheck disable=SC2016 + DB_PASS="$(php -r 'echo urldecode((string) ($argv[1] ?? ""));' -- "$_DB_PASS")" + export DB_USER DB_PASS + fi + + # Split host:port. `[ipv6]:port` is NOT supported (yet) — the + # MariaDB / MySQL drivers accept it but the URL parsing here is + # deliberately simple. Self-hosters using IPv6 set DB_HOST + DB_PORT + # explicitly. + case "$hostport" in + *:*) + DB_HOST="${hostport%:*}" + DB_PORT="${hostport##*:}" + ;; + *) + DB_HOST="$hostport" + ;; + esac + export DB_HOST + [ -n "${DB_PORT:-}" ] && export DB_PORT + + # `/dbname` becomes DB_NAME; strip leading `/` and any `?query` + # tail. The `charset=` query param is honoured if present. + if [ -n "$path_and_query" ]; then + path_only="${path_and_query%%\?*}" + DB_NAME="${path_only#/}" + export DB_NAME + + if [ "$path_and_query" != "$path_only" ]; then + query="${path_and_query#*\?}" + # Look for `charset=...` in the query string. POSIX-grep: + # `\(...\)` capture, `\1` backref. + charset_val="$(echo "$query" | sed -n 's/.*charset=\([^&]*\).*/\1/p')" + if [ -n "$charset_val" ]; then + DB_CHARSET="$charset_val" + export DB_CHARSET + fi + fi + fi +} + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +# +# Resolved AFTER *_FILE secrets and DATABASE_URL parsing so an env var +# present on either path takes precedence over the default below. +apply_defaults() { + : "${DB_HOST:=db}" + : "${DB_PORT:=3306}" + : "${DB_NAME:=sourcebans}" + : "${DB_USER:=sourcebans}" + : "${DB_PASS:=}" + : "${DB_PREFIX:=sb}" + : "${DB_CHARSET:=utf8mb4}" + : "${STEAMAPIKEY:=}" + : "${SB_EMAIL:=}" + # SB_SECRET_KEY: never auto-regenerated on container restart (would + # invalidate every JWT cookie). Auto-generated ONCE at first config + # render below; empty here means render_config will mint a fresh one. + : "${SB_SECRET_KEY:=}" + + # PORT — Render/Fly/Heroku-style platforms inject this. Default 80 + # matches the EXPOSE in the Dockerfile. + : "${PORT:=80}" + + # Trusted-proxy CIDR list — defaults to "no proxy" so plain-Docker + # deploys aren't accidentally trust-everyone (per spec). + : "${SBPP_TRUSTED_PROXIES:=}" + + # Path to config.php. Defaults to in-tree `${WEB_ROOT}/config.php` + # for backward compat with the wizard / dev image. Operators can + # mount a Docker secret at /run/secrets/sbpp-config.php and set + # `SBPP_CONFIG_PATH=/run/secrets/sbpp-config.php` to keep the + # secret out of the container's writable layer. + : "${SBPP_CONFIG_PATH:=${WEB_ROOT}/config.php}" + + # First-boot admin seed. The four vars below are the friendly + # inputs platform deploy forms prompt for. If any is blank, the + # entrypoint refuses to seed the admin and prints a clear next-step + # so the operator knows to set them or run the install wizard + # manually after first boot. + : "${INITIAL_ADMIN_NAME:=}" + : "${INITIAL_ADMIN_STEAM:=}" + : "${INITIAL_ADMIN_EMAIL:=}" + : "${INITIAL_ADMIN_PASSWORD:=}" + + # `SB_NEW_SALT` mirrors the legacy install wizard's value; the + # panel's password layer no longer uses this for new accounts but + # the constant must be defined or several legacy code paths trip + # `Undefined constant`. Keep the value in sync with the install + # wizard (`SB_NEW_SALT='$5$'`). + : "${SB_NEW_SALT:=\$5\$}" + + export DB_HOST DB_PORT DB_NAME DB_USER DB_PASS DB_PREFIX DB_CHARSET \ + STEAMAPIKEY SB_EMAIL SB_SECRET_KEY PORT SBPP_TRUSTED_PROXIES \ + SBPP_CONFIG_PATH \ + INITIAL_ADMIN_NAME INITIAL_ADMIN_STEAM INITIAL_ADMIN_EMAIL \ + INITIAL_ADMIN_PASSWORD SB_NEW_SALT +} + +# --------------------------------------------------------------------------- +# CRIT-6: identifier validation +# --------------------------------------------------------------------------- +# +# Several env vars flow into shell `sed` substitutions, SQL string +# literals, and SQL identifiers (the bareword between backticks in +# `:prefix_admins`). A value with `/`, `&`, `\n`, `'`, `;`, `"`, or a +# backtick would either break the sed expression's delimiter or escape +# SQL string context — depending on the call site, that's a syntax +# error at best and a SQL-injection vector at worst. +# +# The wizard's `sbpp_install_validate_prefix()` already pins the +# `prefix` shape to `^[A-Za-z0-9_]+$`. We mirror the contract here so +# the entrypoint path has the same guarantee BEFORE any substitution +# runs. The shapes: +# +# - DB_PREFIX, DB_NAME, DB_USER — SQL identifiers (DB_NAME is the +# name of the database between `USE` / `mysql --database` / +# `dbname=` in the DSN; DB_USER is the auth user). Allow +# `[A-Za-z0-9_]+`. +# - DB_HOST — hostname or IPv4 literal. Allow `[A-Za-z0-9._-]+`. +# (IPv6 isn't supported by the URL parser yet; operators using +# IPv6 set DB_HOST + DB_PORT directly and the literal +# `2001:db8::1` form would tickle the colon delimiter — for now +# we reject it loud rather than silently mis-parse.) +validate_identifiers() { + # Allow this function to "fail" via the regex case-statement + # without nuking the shell under `set -e`. + case "${DB_PREFIX:-}" in + ''|*[!A-Za-z0-9_]*) + die "DB_PREFIX='${DB_PREFIX:-}' must match [A-Za-z0-9_]+ — used as the literal table-name prefix in SQL identifiers." + ;; + esac + case "${DB_NAME:-}" in + ''|*[!A-Za-z0-9_]*) + die "DB_NAME='${DB_NAME:-}' must match [A-Za-z0-9_]+ — used as the SQL database name in DDL." + ;; + esac + case "${DB_USER:-}" in + ''|*[!A-Za-z0-9_]*) + die "DB_USER='${DB_USER:-}' must match [A-Za-z0-9_]+." + ;; + esac + case "${DB_HOST:-}" in + ''|*[!A-Za-z0-9._-]*) + die "DB_HOST='${DB_HOST:-}' must match [A-Za-z0-9._-]+ (hostnames + IPv4; IPv6 literals not supported here — set DB_HOST + DB_PORT explicitly without the bracketed form)." + ;; + esac + case "${DB_PORT:-}" in + ''|*[!0-9]*) + die "DB_PORT='${DB_PORT:-}' must be numeric." + ;; + esac + case "${DB_CHARSET:-}" in + ''|*[!A-Za-z0-9_]*) + die "DB_CHARSET='${DB_CHARSET:-}' must match [A-Za-z0-9_]+." + ;; + esac +} + +# --------------------------------------------------------------------------- +# Step 2: Apache config (PORT + mod_remoteip) +# --------------------------------------------------------------------------- +configure_apache() { + log "step 2: configuring Apache (PORT=${PORT}, trusted proxies: ${SBPP_TRUSTED_PROXIES:-})" + + # Rewrite `Listen 80` -> `Listen ${PORT}` in /etc/apache2/ports.conf + # and `` in the default site. Only when PORT + # differs from 80 (the image default) — saves a write on the + # common case. + if [ "$PORT" != "80" ]; then + sed -ri "s/^Listen 80\$/Listen ${PORT}/" /etc/apache2/ports.conf + sed -ri "s///" \ + /etc/apache2/sites-available/000-default.conf + fi + + # Trusted-proxy config — written if SBPP_TRUSTED_PROXIES is set, + # removed (if it exists from a prior boot) otherwise. mod_remoteip + # is enabled in the Dockerfile; the conf below adds the per-deploy + # CIDR list. + # + # When trusted: also mirror X-Forwarded-Proto -> $_SERVER['HTTPS'] + # via SetEnvIfExpr so the panel's Sbpp\Auth\Host::isSecure() check + # picks up the original scheme. (Host::isSecure() also reads + # HTTP_X_FORWARDED_PROTO directly as a pre-existing fallback; + # SetEnvIfExpr is the cleaner Apache-side shape that doesn't depend + # on the PHP code's specific check.) + conf_file="/etc/apache2/conf-enabled/zz-sbpp-trusted-proxy.conf" + if [ -n "$SBPP_TRUSTED_PROXIES" ]; then + { + printf '# Generated by prod-entrypoint on %s.\n' "$(date -u +%FT%TZ)" + printf '# Per-deploy trusted-proxy list (SBPP_TRUSTED_PROXIES env var).\n' + for proxy in $SBPP_TRUSTED_PROXIES; do + printf 'RemoteIPInternalProxy %s\n' "$proxy" + done + # Apache 2.4+: when X-Forwarded-Proto comes from a trusted + # proxy (i.e. mod_remoteip rewrote REMOTE_ADDR), mirror it + # into HTTPS so PHP-side checks of $_SERVER['HTTPS'] + # see the upstream scheme. + cat <<'CONF' + +# Mirror X-Forwarded-Proto -> $_SERVER['HTTPS'] for PHP code that +# checks the legacy 'HTTPS' key directly. Sbpp\Auth\Host::isSecure() +# also reads HTTP_X_FORWARDED_PROTO directly, so this is belt-and- +# suspenders for any third-party plugin / theme code that reaches +# for the legacy key. +SetEnvIfExpr "req('X-Forwarded-Proto') == 'https'" HTTPS=on +CONF + } > "$conf_file" + elif [ -f "$conf_file" ]; then + rm -f "$conf_file" + fi +} + +# --------------------------------------------------------------------------- +# Step 3: wait for DB +# --------------------------------------------------------------------------- +# +# HIGH-3 of the #1381 review: the spec explicitly forbids shipping +# `default-mysql-client` in the runtime image. Pre-fix the entrypoint +# reached for `mysqladmin ping` here and `mysql < schema.sql` in +# `first_boot_install` — both removed. The PHP-side replacement is +# `docker/php/sb-db.php`, copied into the image at +# /usr/local/lib/sbpp/sb-db.php (see Dockerfile.prod for the COPY +# line) and called with the `ping` / `exec` / `has-version-row` +# subcommands. +wait_for_db() { + log "step 3: waiting for DB at ${DB_HOST}:${DB_PORT} (user=${DB_USER}) ..." + tries=60 + while [ "$tries" -gt 0 ]; do + if php "${SBPP_LIB_DIR}/sb-db.php" ping 2>/dev/null; then + log "step 3: DB is up" + return + fi + tries=$((tries - 1)) + sleep 1 + done + die "DB at ${DB_HOST}:${DB_PORT} never came up — giving up after 60s" +} + +# Run a SQL command via the panel's DB user. Reads its body from +# stdin. Replaces the legacy `mysql < … `-shaped helper. +run_sql() { + php "${SBPP_LIB_DIR}/sb-db.php" exec +} + +# --------------------------------------------------------------------------- +# Step 4: render config.php +# --------------------------------------------------------------------------- +render_config() { + if [ -s "${SBPP_CONFIG_PATH}" ]; then + # MED-2 of the #1381 review: a partial / corrupted config.php + # left over from a crashed render run would otherwise propagate + # to every subsequent boot as a fatal at request time (Apache + # children syntax-error on every page load). Catching the + # syntax error here surfaces the failure in the boot logs the + # operator is already watching, and `die` triggers the + # orchestrator's restart loop with a clear message — better + # than a panel that returns 500 on every request. + if ! php -l "${SBPP_CONFIG_PATH}" >/dev/null 2>&1; then + die "${SBPP_CONFIG_PATH} exists but has a PHP syntax error (corrupted? hand-edited?). Delete it to let the entrypoint regenerate, or fix the syntax. \`php -l ${SBPP_CONFIG_PATH}\` shows the line." + fi + log "step 4: ${SBPP_CONFIG_PATH} already present — leaving alone (config.php is the install-state sentinel)" + return + fi + log "step 4: rendering ${SBPP_CONFIG_PATH} from environment" + + if [ -z "$SB_SECRET_KEY" ]; then + SB_SECRET_KEY="$(openssl rand -base64 47 | tr -d '\n')" + log "step 4: minted fresh SB_SECRET_KEY (47-byte base64) — persist by re-reading from this file or set SB_SECRET_KEY env var" + export SB_SECRET_KEY + fi + + # Single-quote string literals; escape `'` and `\` to defend the + # PHP file from values that might break out of the literal. Mirror + # the wizard's `sbpp_install_render_config()` shape (page.5.php). + cfg_dir="$(dirname "$SBPP_CONFIG_PATH")" + + # MED-5 of the #1381 review: if the operator set SBPP_CONFIG_PATH + # to a path whose parent directory isn't mounted (typo in the + # bind-mount target, a Docker secret that didn't sync, etc.), + # mkdir -p will silently create the dir on the container's + # writable layer and the freshly-rendered config.php will vanish + # the next time the operator recreates the container — a subtle + # "why does my panel keep losing its config?" trap. Detect the + # two pathologies and surface them loud: + # + # 1. Parent dir missing when SBPP_CONFIG_PATH was explicitly + # set: die — refuse to create a config the operator expected + # to land on a volume that isn't there. + # + # 2. Parent dir on the same st_dev as `/` when SBPP_CONFIG_PATH + # was explicitly set: warn — heuristic check that the path + # isn't a mount. Common false-positive: the operator + # intentionally writes config.php into the writable layer + # and pairs that with an explicit SB_SECRET_KEY in env. So + # this stays a warn, not a die. + # + # The default case (SBPP_CONFIG_PATH unset → ${WEB_ROOT}/config.php) + # is on the writable layer by design; skip the check. + if [ "${SBPP_CONFIG_PATH}" != "${WEB_ROOT}/config.php" ]; then + if [ ! -d "$cfg_dir" ]; then + die "SBPP_CONFIG_PATH=${SBPP_CONFIG_PATH} but its parent directory ${cfg_dir} does not exist. Mount the directory (or its containing volume / secret), or unset SBPP_CONFIG_PATH to write config.php into the image's writable layer." + fi + if [ "$(stat -c %d "$cfg_dir" 2>/dev/null)" = "$(stat -c %d / 2>/dev/null)" ]; then + warn "SBPP_CONFIG_PATH=${SBPP_CONFIG_PATH} is on the container's writable layer (same st_dev as /). config.php will NOT persist across container recreations. If this is intentional, also set SB_SECRET_KEY explicitly so JWT cookies survive." + fi + else + [ -d "$cfg_dir" ] || mkdir -p "$cfg_dir" + fi + + sb_esc() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/\\\\'/g" + } + + cat > "${SBPP_CONFIG_PATH}" </dev/null; then + log "step 5: :${DB_PREFIX}_settings.config.version row exists — panel already installed, skipping schema bootstrap" + return + fi + log "step 5: first-boot install (schema + data + seed admin)" + + schema_dir="${WEB_ROOT}/install/includes/sql" + if [ ! -f "${schema_dir}/struc.sql" ] || [ ! -f "${schema_dir}/data.sql" ]; then + die "first-boot install requested but schema files missing under ${schema_dir} — image is broken?" + fi + + # HIGH-5 of the #1381 review: pre-fix this was a soft `log` line + # ("log in as the CONSOLE row only") that left the operator + # locked out — the CONSOLE row carries an empty password and + # NormalAuthHandler rejects empty auth, so the "log in as CONSOLE" + # nudge was impossible to follow. With SBPP_AUTO_INSTALL=1, the + # operator opted INTO headless install — refuse to bring the + # panel up half-installed. + if [ -z "$INITIAL_ADMIN_NAME" ] \ + || [ -z "$INITIAL_ADMIN_STEAM" ] \ + || [ -z "$INITIAL_ADMIN_EMAIL" ] \ + || [ -z "$INITIAL_ADMIN_PASSWORD" ]; then + die "SBPP_AUTO_INSTALL=1 requires all four INITIAL_ADMIN_{NAME,STEAM,EMAIL,PASSWORD} env vars to be set so a headless install can seed an Owner-flagged admin. Either set them in your deploy env, or set SBPP_AUTO_INSTALL=0 to skip the headless install and run the wizard manually at /install/." + fi + + # Pipe schema with substitutions (mirror docker/db-init/00-render-schema.sh + # exactly — same prefix, same charset, same render order). The + # `validate_identifiers` call at boot already restricted DB_PREFIX + + # DB_CHARSET to [A-Za-z0-9_]+, so the sed replacement can't break + # the schema's grammar. + log "step 5: loading schema (prefix=${DB_PREFIX}, charset=${DB_CHARSET})" + sed -e "s/{prefix}/${DB_PREFIX}/g" -e "s/{charset}/${DB_CHARSET}/g" \ + "${schema_dir}/struc.sql" | run_sql + sed -e "s/{prefix}/${DB_PREFIX}/g" -e "s/{charset}/${DB_CHARSET}/g" \ + "${schema_dir}/data.sql" | run_sql + + seed_initial_admin +} + +# Hash the password with PHP's password_hash() (BCRYPT) — same shape as +# the wizard's page.5.php. `php -r` runs in the runtime image without +# touching init.php (no panel chrome / no DB connection). +seed_initial_admin() { + log "step 5: seeding initial admin '${INITIAL_ADMIN_NAME}'" + + # STEAM_1 -> STEAM_0 normalisation (mirror page.5.php). The panel + # runtime expects the STEAM_0 form; admins logging in via Steam + # get STEAM_0 from OpenID anyway. + authid="$(printf '%s' "$INITIAL_ADMIN_STEAM" | sed 's/^STEAM_1/STEAM_0/')" + + # shellcheck disable=SC2016 + # ^ the single-quoted body is intentional — `$argv[1]` is PHP's + # argv, not a shell variable. Wrapping in single quotes keeps + # the shell from rewriting the literal `$argv` before php sees + # it. The password itself is passed as a CLI argument + # (`"$INITIAL_ADMIN_PASSWORD"`), so it never reaches the shell's + # word-splitting / glob phases — `password_hash()` receives the + # literal byte sequence the operator set. + pwhash="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$INITIAL_ADMIN_PASSWORD")" + if [ -z "$pwhash" ]; then + die "password_hash() returned empty for INITIAL_ADMIN_PASSWORD — refusing to seed admin" + fi + + # gid=-1, extraflags=16777216 (1<<24 = ADMIN_OWNER), immunity=100. + # Same shape as page.5.php's INSERT. + # + # sb-db.php exec connects via the panel's runtime user/pass that + # was already verified by wait_for_db. The password hash and the + # admin name come from operator env vars, so they're trusted by + # definition (the operator set them). The authid is regex- + # normalised above. The email is validated by the panel UI later + # but here we just splat it in. The single-quote escape in + # `sql_escape` defends the string literal against `'` / `\`. + sql_escape() { + # MySQL string-literal escape: backslash + single-quote. + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/\\\\'/g" + } + n="$(sql_escape "$INITIAL_ADMIN_NAME")" + a="$(sql_escape "$authid")" + p="$(sql_escape "$pwhash")" + e="$(sql_escape "$INITIAL_ADMIN_EMAIL")" + + cat <.php` against the recorded `config.version` row +# in `:prefix_settings`, runs each script in a per-script php -r so +# the SQL inside their `$this->dbs->...` calls executes against the +# panel's runtime DB. +# +# Each script is required to be idempotent (per AGENTS.md "Updater +# migrations" contract), so re-running on every container start is safe. +run_pending_migrations() { + log "step 6: checking for pending updater migrations" + + if [ ! -f "${WEB_ROOT}/updater/store.json" ] \ + || [ ! -d "${WEB_ROOT}/updater/data" ]; then + log "step 6: updater not on disk — skipping (image already pruned post-install?)" + return + fi + + # Use PHP's own JSON parser + the panel's Database class to drive + # the migration runner. This re-uses the panel's autoload + the + # existing Updater class, so the codepath is byte-identical to the + # /updater/ web entrypoint minus the HTML render. + # + # CRIT-5 of the #1381 review: pre-fix the inline PHP script + # ALWAYS returned exit 0 even when a migration failed midway. + # Updater::update() records "Error executing: /updater/data/.php. + # Stopping Update!" / "Update Failed!" into its message + # stack but never throws — and the surrounding script didn't + # inspect the stack, so the shell-side `if [ "$rc" -ne 0 ]` + # check was dead code. Result: a half-migrated schema booted + # silently, and the panel's first request hit a column that + # didn't yet exist. + # + # Post-fix: after the `Updater::update()` call returns, inspect + # the message-stack lines for either marker and `exit(1)` if any + # match. The shell `die` below now actually fires when the PHP + # detects a failure. + php <<'PHP' +.php files via relative paths. Same shape +// as the legacy /updater/index.php (which is loaded with cwd = +// /var/www/.../web/updater). +chdir($root . '/updater'); +require_once $root . '/updater/Updater.php'; + +try { + $updater = new \Updater($pdo); +} catch (\Throwable $e) { + fwrite(STDERR, "[prod-entrypoint][step 6] Updater constructor threw: " . $e->getMessage() . "\n"); + exit(1); +} + +$failed = false; +foreach ($updater->getMessageStack() as $line) { + $text = strip_tags((string) $line); + fwrite(STDERR, "[prod-entrypoint][step 6] " . $text . "\n"); + // Updater::update() emits one of these two markers when a + // per-script require returns falsy or a file is missing on + // disk. Substring-match both because the upstream stack + // strings include `...` formatting we already stripped. + // + // Source-of-truth: web/updater/Updater.php's `update()` method + // — search for "Error executing" and "Update Failed!". + if (str_contains($text, 'Error executing:') + || str_contains($text, 'Update Failed!')) { + $failed = true; + } +} + +if ($failed) { + fwrite(STDERR, "[prod-entrypoint][step 6] one or more migrations failed — refusing to continue with a partially-upgraded schema\n"); + exit(1); +} +PHP + + rc=$? + if [ "$rc" -ne 0 ]; then + die "updater run failed (exit $rc) — refusing to start panel against a partially-upgraded schema" + fi +} + +# --------------------------------------------------------------------------- +# Step 7: strip install/ + updater/ from the writable layer +# --------------------------------------------------------------------------- +# +# Per web/init-recovery.php's sbpp_check_install_guard(), the panel +# runtime refuses to boot if `install/` or `updater/` is on disk +# (post-#1335 contract). Production MUST NOT define +# SBPP_DEV_KEEP_INSTALL — the only legitimate way to make the guard +# pass is to actually remove the directories. The dev image bind-mounts +# the worktree (which carries both from git) and has its own escape +# hatch; this image's writable layer is the place to make the +# directories vanish. +# +# `rm -rf` against the runtime user's writable layer; failure is +# fatal (otherwise the panel would die on the next request with the +# install-blocked recovery page, which is the wrong UX for a +# production deploy). +strip_install_dirs() { + # HIGH-5 partner: when SBPP_AUTO_INSTALL=0, the operator opted + # OUT of the headless install in step 5 — typically because they + # want to drive the wizard manually OR because they're pointing + # at a pre-populated managed DB and the wizard's already-installed + # guard will refuse to start once their config.php lands. In + # either case, install/ MUST stay on disk for the wizard to be + # reachable. + # + # The panel-runtime install/-presence guard is the friction + # surface that keeps the operator honest: hitting `/` lands on + # the recovery page that tells them to delete install/ once + # post-install cleanup is done. We deliberately don't reach for + # SBPP_DEV_KEEP_INSTALL here — that constant is named loudly so + # it's visibly wrong in production, and we want the operator to + # SEE the guard fire (and the friendly recovery copy) until they + # complete the wizard and clean up. + if [ "$SBPP_AUTO_INSTALL" != "1" ]; then + log "step 7: SBPP_AUTO_INSTALL=0 — leaving install/ + updater/ in place (wizard reachable at /install/)" + return + fi + log "step 7: removing install/ + updater/ from writable layer (panel-runtime guard contract)" + rm -rf "${WEB_ROOT}/install" "${WEB_ROOT}/updater" || die "couldn't strip install/+updater/ — see error above" +} + +# --------------------------------------------------------------------------- +# Step 8: writable cache + demos +# --------------------------------------------------------------------------- +ensure_writable() { + log "step 8: ensuring writable cache/templates_c/demos" + # Pre-created in the Dockerfile, but the operator's docker-compose + # binds named volumes over each path — and a fresh volume's first + # mount inherits root:root with restrictive perms. chown + chmod + # makes those volumes writable on every boot (idempotent — no-op + # when the perms are already right). + for dir in cache cache/sessions templates_c demos; do + path="${WEB_ROOT}/${dir}" + [ -d "$path" ] || mkdir -p "$path" + chown -R www-data:www-data "$path" + chmod -R 0775 "$path" + done +} + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- +main() { + log "starting (image: $(cat /etc/debian_version 2>/dev/null || echo unknown), php: $(php -r 'echo PHP_VERSION;'))" + + resolve_secrets # step 1a + parse_database_url # step 1b + apply_defaults # step 1c + validate_identifiers # step 1d (CRIT-6: identifier shapes) + configure_apache # step 2 + wait_for_db # step 3 + render_config # step 4 + first_boot_install # step 5 + run_pending_migrations # step 6 + strip_install_dirs # step 7 + ensure_writable # step 8 + + # CRIT-2 of the #1381 review: every env var that carries a + # secret (DB password, JWT signing key, the initial-admin + # password seed) MUST be unset BEFORE `exec apache2-foreground`. + # The exec'd process inherits the entrypoint's environment, and + # every Apache child PHP request can read those values through + # `$_ENV` / `$_SERVER` / `getenv()` / `phpinfo()` — a stored + # `\n"); + exit(2); +} + +/** + * Open a PDO connection using the entrypoint's env vars. + * + * `dbname=` is included in the DSN by default because the panel's + * DB user is typically scoped to a single database (the compose + * stack pre-creates it via mariadb's `MYSQL_DATABASE` env var; the + * vast majority of managed-DB grants are scoped to one db). For the + * `ping` subcommand pass `$includeDbName=false` so we only need + * server-level auth — useful when the panel's DB user has been + * pre-provisioned but the database is created out-of-band on + * first boot. + * + * @throws \PDOException + */ +function sb_open_pdo(bool $includeDbName = true): PDO +{ + $host = getenv('DB_HOST') ?: 'db'; + $port = (int) (getenv('DB_PORT') ?: '3306'); + $name = getenv('DB_NAME') ?: 'sourcebans'; + $user = getenv('DB_USER') ?: 'sourcebans'; + $pass = (string) getenv('DB_PASS'); + $charset = getenv('DB_CHARSET') ?: 'utf8mb4'; + + $dsn = "mysql:host={$host};port={$port};charset={$charset}"; + if ($includeDbName) { + $dsn .= ";dbname={$name}"; + } + + // PHP 8.5 deprecated the bare `PDO::MYSQL_ATTR_INIT_COMMAND` + // form in favour of the namespaced `Pdo\Mysql::ATTR_INIT_COMMAND`; + // the panel's composer floor is PHP 8.5 so the new shape is + // safe. Falling back to the legacy constant via `defined()` so + // an operator running this helper standalone against an older + // PHP image (e.g. for diagnostics) doesn't faceplant. + $initAttr = defined('Pdo\\Mysql::ATTR_INIT_COMMAND') + ? \Pdo\Mysql::ATTR_INIT_COMMAND + : PDO::MYSQL_ATTR_INIT_COMMAND; + + return new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 5, + PDO::ATTR_EMULATE_PREPARES => false, + $initAttr => "SET NAMES {$charset}", + ]); +} + +switch ($cmd) { + case 'ping': + try { + // No need to do anything beyond instantiate — PDO::__construct + // performs the actual auth+connect, and EXCEPTION mode + // throws on failure. Server-level connect (no `dbname=`) + // so the ping succeeds on a freshly-provisioned DB user + // before the database itself is created on first boot. + sb_open_pdo(false); + exit(0); + } catch (\Throwable $e) { + // Quiet on stderr — the entrypoint loops on this 60 times + // and stderr-spamming each failure is pure noise. Only the + // final "DB never came up" line from the entrypoint + // matters for diagnosis. + exit(1); + } + // unreachable + break; + + case 'has-version-row': + $prefix = $argv[2] ?? ''; + // Mirror entrypoint's `validate_identifiers` contract — DB_PREFIX + // must be `[A-Za-z0-9_]+`. The entrypoint always validates + // before reaching here, but defending the helper too means it + // can be called standalone safely (e.g. for ops debugging). + if ($prefix === '' || preg_match('/^[A-Za-z0-9_]+$/', $prefix) !== 1) { + fwrite(STDERR, "sb-db.php has-version-row: PREFIX must match [A-Za-z0-9_]+\n"); + exit(2); + } + try { + $pdo = sb_open_pdo(); + $stmt = $pdo->query("SELECT 1 FROM `{$prefix}_settings` WHERE setting = 'config.version' LIMIT 1"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_NUM) : false; + exit($row ? 0 : 1); + } catch (\Throwable $e) { + // Two pathologies both signal "not installed": + // - PDOException 42S02 (table missing) — fresh DB, the + // schema bootstrap hasn't run yet. + // - Anything else (auth, network, etc.) — we want the + // entrypoint to handle it via the regular failure + // path, not silently skip the install. Signal "not + // present" so the entrypoint's wait_for_db / + // first_boot_install pair handles the underlying + // failure with the right surface. + exit(1); + } + // unreachable + break; + + case 'exec': + $sql = stream_get_contents(STDIN); + if ($sql === false || trim($sql) === '') { + fwrite(STDERR, "sb-db.php exec: empty STDIN — refusing to no-op silently\n"); + exit(2); + } + try { + $pdo = sb_open_pdo(); + } catch (\Throwable $e) { + fwrite(STDERR, 'sb-db.php exec: PDO connect failed: ' . $e->getMessage() . "\n"); + exit(1); + } + $statements = sb_split_sql($sql); + if ($statements === []) { + fwrite(STDERR, "sb-db.php exec: STDIN contained no executable statements\n"); + exit(2); + } + foreach ($statements as $i => $stmt) { + try { + $pdo->exec($stmt); + } catch (\Throwable $e) { + $preview = preg_replace('/\s+/', ' ', $stmt) ?? $stmt; + if (strlen($preview) > 160) { + $preview = substr($preview, 0, 157) . '...'; + } + fwrite(STDERR, sprintf( + "sb-db.php exec: statement #%d failed: %s\n -> %s\n", + $i + 1, + $e->getMessage(), + $preview + )); + exit(1); + } + } + exit(0); + // unreachable + break; + + default: + fwrite(STDERR, "sb-db.php: unknown command '{$cmd}' (expected: ping | has-version-row | exec)\n"); + exit(2); +} + +/** + * Split a SQL script on top-level `;` while respecting single-quoted, + * double-quoted, and backtick-quoted strings (including `\\`-escaped + * quote-in-string), MySQL `--`-to-EOL line comments, MySQL `#`-to-EOL + * line comments, and `/* … *\/` block comments. The output is a list + * of trimmed, non-empty statements ready for `PDO::exec()`. + * + * Conservative parser; not a full SQL grammar. The schema files we + * ship (`install/includes/sql/struc.sql`, `install/includes/sql/data.sql`) + * are well-formed, but values inside string literals can contain + * `;` and the naive `explode(';', $sql)` would split mid-literal + * (the install wizard's page.5.php uses `explode(';', $sql)` against + * the seed data.sql today and gets away with it because data.sql + * happens not to contain `;` inside any string literal — fragile). + * This parser is the load-bearing replacement for the previous + * `mysql < schema.sql` shape, so it has to be right. + * + * @return list + */ +function sb_split_sql(string $sql): array +{ + $out = []; + $buf = ''; + $n = strlen($sql); + $i = 0; + /** @var null|string $inStr The active string-delimiter, or null. */ + $inStr = null; + while ($i < $n) { + $ch = $sql[$i]; + if ($inStr !== null) { + $buf .= $ch; + // `\` inside a string escapes the next character — including + // the delimiter. SQL passwords with `\'` survive this. + if ($ch === '\\' && $i + 1 < $n) { + $buf .= $sql[$i + 1]; + $i += 2; + continue; + } + if ($ch === $inStr) { + $inStr = null; + } + $i++; + continue; + } + // Line comment `-- …\n`. MySQL requires whitespace after `--` + // but we accept the bare-`--` form too for robustness. + if ($ch === '-' && $i + 1 < $n && $sql[$i + 1] === '-') { + while ($i < $n && $sql[$i] !== "\n") { + $buf .= $sql[$i++]; + } + continue; + } + // `#` line comment (MySQL extension). + if ($ch === '#') { + while ($i < $n && $sql[$i] !== "\n") { + $buf .= $sql[$i++]; + } + continue; + } + // Block comment `/* … */`. Standard SQL; doesn't nest. + if ($ch === '/' && $i + 1 < $n && $sql[$i + 1] === '*') { + $buf .= '/*'; + $i += 2; + while ($i + 1 < $n && !($sql[$i] === '*' && $sql[$i + 1] === '/')) { + $buf .= $sql[$i++]; + } + if ($i + 1 < $n) { + $buf .= '*/'; + $i += 2; + } + continue; + } + if ($ch === '"' || $ch === "'" || $ch === '`') { + $inStr = $ch; + $buf .= $ch; + $i++; + continue; + } + if ($ch === ';') { + $stmt = trim($buf); + if ($stmt !== '') { + $out[] = $stmt; + } + $buf = ''; + $i++; + continue; + } + $buf .= $ch; + $i++; + } + $tail = trim($buf); + if ($tail !== '') { + $out[] = $tail; + } + return $out; +} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 3ae075203..d336e7ff3 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -97,6 +97,7 @@ export default defineConfig({ { label: 'Overview', slug: 'getting-started/overview' }, { label: 'Requirements', slug: 'getting-started/prerequisites' }, { label: 'Quickstart', slug: 'getting-started/quickstart' }, + { label: 'Quickstart (Docker)', slug: 'getting-started/quickstart-docker' }, ], }, { diff --git a/docs/src/content/docs/getting-started/overview.mdx b/docs/src/content/docs/getting-started/overview.mdx index 28c15cb3f..15e124a40 100644 --- a/docs/src/content/docs/getting-started/overview.mdx +++ b/docs/src/content/docs/getting-started/overview.mdx @@ -5,7 +5,7 @@ sidebar: order: 1 --- -import { CardGrid, LinkCard } from '@astrojs/starlight/components'; +import { CardGrid, LinkCard, Tabs, TabItem, Aside } from '@astrojs/starlight/components'; This page is a quick mental model of how SourceBans++ is put together, so the rest of the guide reads as "wire up these named pieces" rather @@ -61,8 +61,7 @@ A first-time install walks through, in order: 1. **A database** — MariaDB or MySQL, hosted wherever's convenient. See [Database setup](/setup/mariadb/). -2. **The web panel** — Upload, run the install wizard, log in. - See [Quickstart](/getting-started/quickstart/). +2. **The web panel** — Pick an install path below. 3. **One or more game servers** — Add each to the panel to get a `ServerID`. See [Adding a server](/setup/adding-server/). 4. **The SourceMod plugins** — Drop them onto each game server, edit @@ -74,6 +73,49 @@ A first-time install walks through, in order: After that you're in day-to-day operation: people get banned, you review appeals, you tune permissions. +## Pick an install path + +We ship three install paths for the panel. The plugin side is the +same regardless of which one you pick — these only differ in how +the **web panel** gets stood up. + + + + **Best when:** you already run a webserver, share hosting with + other apps, or want maximum control over PHP / Apache / nginx + config. The traditional install path: download a release zip, + upload via FTP / cPanel / SSH, walk the install wizard once. + + Step it out with the **[tarball quickstart](/getting-started/quickstart/)**. + + + + **Best when:** you own the host, want one-command upgrades + (`docker compose pull && up -d`), zero-touch TLS, or + reproducible deploys across machines. Ships a hardened + multi-arch image (`ghcr.io/sbpp/sourcebans-pp`) signed with + Sigstore cosign. + + Step it out with the **[Docker quickstart](/getting-started/quickstart-docker/)**. + + + + **Best when:** you don't want to manage a host at all and your + panel doesn't need exotic customisation. Deploy buttons for + DigitalOcean App Platform, Railway, Render, and Fly.io are + tracked in [issue #1382](https://github.com/sbpp/sourcebans-pp/issues/1382). + + + + + ## Key concepts A handful of terms come up over and over in the panel and in this @@ -112,11 +154,16 @@ cause. + diff --git a/docs/src/content/docs/getting-started/prerequisites.mdx b/docs/src/content/docs/getting-started/prerequisites.mdx index 0c16e58b0..346eaca30 100644 --- a/docs/src/content/docs/getting-started/prerequisites.mdx +++ b/docs/src/content/docs/getting-started/prerequisites.mdx @@ -15,8 +15,48 @@ these in a few clicks. ## Web side -The web panel runs on PHP and stores its data in a MySQL- or -MariaDB-compatible database. +How you stand up the web panel decides which prerequisites apply. +Pick the path you'll use: + + + + The web panel runs on PHP and stores its data in a MySQL- or + MariaDB-compatible database. See the **Database**, **PHP**, and + **Composer** sections below for the per-component versions and + extensions you need. + + + + You need: + + - **Docker 24+** — `docker --version` should print `24.x` or + newer. Docker Desktop ships a current version; Docker Engine + on Linux can be upgraded via the [official install + instructions](https://docs.docker.com/engine/install/). + - **Docker Compose v2** — `docker compose version` should + print `v2.x` or newer. v2 ships as a Docker CLI plugin (built + into Docker Desktop; available as `docker-compose-plugin` on + Debian/Ubuntu, `docker-compose` on RHEL/Fedora). + - **At least 512 MB of free RAM** on the host. The panel itself + idles around 150 MB; MariaDB takes the rest. + + You do NOT need PHP, Composer, MariaDB, or a webserver installed + on the host — the image bundles all four. The **Database** / + **PHP** sections below are informational only for the Docker + install path. + + Step it out in the [Docker quickstart](/getting-started/quickstart-docker/). + + + + The app-platform deploys (DigitalOcean / Railway / Render / + Fly.io) run the same image as the Docker install path. The + platform itself handles provisioning the host, the database, + and TLS termination; your prerequisites are an account on the + platform and your panel's env vars (the **Docker quickstart**'s + section on env vars applies identically). + + ### Database @@ -115,5 +155,22 @@ can't reach a server lives in ## Ready to install? -When the boxes above are ticked, head to the -[Quickstart](/getting-started/quickstart/) and follow it end-to-end. +When the boxes above are ticked, head to the install path you +picked at the top of this page: + + + + [Tarball quickstart](/getting-started/quickstart/) — unzip, + upload, run the wizard. + + + + [Docker quickstart](/getting-started/quickstart-docker/) — + `docker compose up -d` to a fully running panel. + + + + [Docker quickstart](/getting-started/quickstart-docker/) — the + "Deploy with one click" section covers the per-platform shape. + + diff --git a/docs/src/content/docs/getting-started/quickstart-docker.mdx b/docs/src/content/docs/getting-started/quickstart-docker.mdx new file mode 100644 index 000000000..e530924e7 --- /dev/null +++ b/docs/src/content/docs/getting-started/quickstart-docker.mdx @@ -0,0 +1,429 @@ +--- +title: Quickstart (Docker) +description: Deploy SourceBans++ with Docker Compose end-to-end — single host, persistent volumes, automatic TLS via Caddy. +sidebar: + order: 4 + label: Quickstart (Docker) +--- + +import { Tabs, TabItem, Aside, LinkCard, CardGrid, Steps } from '@astrojs/starlight/components'; + +This guide stands up a production SourceBans++ install with **Docker +Compose** in a few minutes. By the end you'll have: + +- The panel running behind your domain with automatic Let's Encrypt + TLS, +- A MariaDB database persisted to a named Docker volume, +- An initial admin account ready to log in. + +The Docker image (`ghcr.io/sbpp/sourcebans-pp`) is a multi-arch build +(linux/amd64 + linux/arm64), signed with Sigstore cosign, and +hardened: it ships no install wizard, no developer tools, and runs +the panel as the non-root `www-data` user. + + + +## Before you start + +You need: + +- **Docker 24+** and **Compose v2** (bundled into Docker Desktop and + recent Docker Engine releases — `docker compose version` should + print `v2.x` or newer). +- **A host with at least 512 MB of free RAM.** The panel itself uses + ~150 MB at idle; MariaDB takes the rest. +- A **Steam Web API key** from + [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) + (free, 30 seconds). +- The **SteamID2** of the account you want as the first admin + (`STEAM_0:0:12345678` form). [SteamID I/O](https://steamid.io/) + converts between formats. + +You do **not** need PHP, Composer, MariaDB, or a webserver installed +on the host — the image carries everything. + +## Step 1 — Get the compose stack + +The compose file + env template + Caddy stub live in the +[sourcebans-pp repo](https://github.com/sbpp/sourcebans-pp). The +simplest path is a sparse fetch of the three files you actually need +into a deployment directory of your own: + +```sh +mkdir -p ~/sourcebans-prod +cd ~/sourcebans-prod +curl -O https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/docker-compose.prod.yml +curl -O https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/.env.example.prod +mkdir -p docker/caddy +curl -o docker/caddy/Caddyfile.example \ + https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/docker/caddy/Caddyfile.example +``` + +A `git clone` of the whole repo works too if you'd rather track +upstream changes that way. + +## Step 2 — Configure environment variables + +The compose file reads its config from a `.env` file in the same +directory. Copy the template and fill in the values: + +```sh +cp .env.example.prod .env +``` + +Open `.env` in your editor. The template is grouped into +**Required**, **Recommended**, **First-boot install**, and +**Optional** sections. At minimum: + + +1. **`SB_SECRET_KEY`** — the JWT signing key. Generate once and + persist it forever (rotating it logs every admin out): + + ```sh + openssl rand -base64 47 + ``` + + Paste the output into `SB_SECRET_KEY=...`. + +2. **`DB_ROOT_PASS`** and **`DB_PASS`** — the MariaDB root password + (used once for first-boot DB creation) and the panel user's + password. Generate two distinct strong passwords: + + ```sh + openssl rand -base64 24 # DB_ROOT_PASS + openssl rand -base64 24 # DB_PASS + ``` + +3. **`STEAMAPIKEY`** — paste your Steam Web API key. + +4. **`INITIAL_ADMIN_*`** — fill in all four fields (name, SteamID, + email, password). The entrypoint seeds an Owner-flagged admin from + these values on first boot only; subsequent boots ignore them. + Change the password from the panel's "Your account" page after + first login. + +5. **`SBPP_DOMAIN`** — if you plan to use the Caddy reverse-proxy + stub for automatic TLS, set this to the public domain that points + at your host (e.g. `panel.yourcommunity.example`). + + +The template is heavily commented; read through the rest of it for +the optional knobs (image-tag pinning, trusted-proxy CIDR ranges, +`DATABASE_URL` for managed databases, the `*_FILE` Docker-secret +pattern). + + + +## Step 3 — Start the stack + +If you're putting the panel behind your own existing reverse proxy +(nginx, Traefik, Cloudflare Tunnel, …) or skipping TLS for a private +network deploy: + +```sh +docker compose -f docker-compose.prod.yml up -d +``` + +The panel is now reachable at `http://your-host:8080`. Point your +existing proxy at port 8080 and you're done. + +If you want **automatic Let's Encrypt TLS** via the bundled Caddy +stub: + + +1. Point your domain's DNS A/AAAA records at the host. + +2. Open `docker-compose.prod.yml` in your editor, find the commented + `caddy:` block near the bottom, and uncomment it. Also uncomment + the `caddy-data:` + `caddy-config:` entries under `volumes:`. + +3. Set `SBPP_TRUSTED_PROXIES=172.16.0.0/12 10.0.0.0/8` in `.env` so + the panel trusts Caddy's `X-Forwarded-*` headers from the Docker + bridge range. (Caddy adds these headers on every reverse-proxy + directive; without trust, the panel would log Caddy's IP instead + of the real client.) + +4. Bring the stack up: + + ```sh + docker compose -f docker-compose.prod.yml up -d + ``` + + Caddy provisions the TLS cert on the first request. Subsequent + restarts reuse the cached cert from the `caddy-data` volume. + + +## Step 4 — Verify and log in + +Watch the container come up: + +```sh +docker compose -f docker-compose.prod.yml logs -f web +``` + +You should see a sequence of `[prod-entrypoint]` lines: + +``` +[prod-entrypoint] starting (image: ...) +[prod-entrypoint] step 2: configuring Apache (PORT=80, trusted proxies: ...) +[prod-entrypoint] step 3: waiting for DB at db:3306 ... +[prod-entrypoint] step 3: DB is up +[prod-entrypoint] step 4: rendering /var/www/html/web/config.php from environment +[prod-entrypoint] step 5: first-boot install (schema + data + seed admin) +[prod-entrypoint] step 5: seeding initial admin '' +[prod-entrypoint] step 6: checking for pending updater migrations +[prod-entrypoint] step 7: removing install/ + updater/ from writable layer +[prod-entrypoint] step 8: ensuring writable cache/templates_c/demos +[prod-entrypoint] boot complete — handing off to: apache2-foreground +``` + +Once the last line lands, the panel is up. Open +`https:///` (or `http://:8080/` if you +skipped Caddy) and log in with the `INITIAL_ADMIN_*` credentials you +set in `.env`. + +The container's healthcheck is `GET /health.php`; you can poke it +directly: + +```sh +curl -s https:///health.php +# OK +``` + +A `200 OK` body means the panel can reach the database. A `503` body +prints the failure reason. + +## Step 5 — Add a game server + +Once you're logged in, the rest of the setup (registering a game +server, configuring SourceMod plugins) follows the same flow as a +tarball install: + + + + + + +The plugins live on your **game** servers and read directly from the +same database the panel writes to — they don't need to know the +panel is running in Docker. + +## Persistent volumes + +`docker-compose.prod.yml` declares four named volumes. Their roles: + +| Volume | Path inside container | Persist? | +| --- | --- | --- | +| `dbdata` | `/var/lib/mysql` | **Required.** The only source of truth for ban / admin / log rows. Loss = panel reset. | +| `demos` | `/var/www/html/web/demos` | **Required.** Uploaded ban-evidence demos; not regenerable from anything else. | +| `cache` | `/var/www/html/web/cache` | Optional. Smarty compile cache + PHP sessions. Wiping logs every admin out but doesn't lose data. | +| `smarty` | `/var/www/html/web/templates_c` | Optional. Same shape as `cache` — rebuilt on demand. | + +Back up the `dbdata` and `demos` volumes regularly. The simplest +backup is a `mariadb-dump` for the DB and a `tar` for the demos: + +```sh +# DB dump +docker compose -f docker-compose.prod.yml exec db \ + mariadb-dump -uroot -p"$DB_ROOT_PASS" --single-transaction sourcebans \ + > backup-$(date +%F).sql + +# Demos +docker run --rm -v _demos:/data -v "$PWD:/backup" alpine \ + tar -czf /backup/demos-$(date +%F).tar.gz -C /data . +``` + +(`` is the project name compose derives from the directory +the compose file lives in — usually the directory's basename, e.g. +`sourcebans-prod_demos`.) + +## Upgrading + +The image is **immutable** — upgrades are a tag bump. Pin +`SBPP_IMAGE_TAG` in `.env` to a specific version for reproducible +deploys: + +```diff +- SBPP_IMAGE_TAG=latest ++ SBPP_IMAGE_TAG=1.7.0 +``` + +Then: + +```sh +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d +``` + +The new container's entrypoint runs any pending updater migrations +against the existing database (idempotent — every migration script +in `web/updater/data/.php` is forward-only and safe to re-run). +The `dbdata` and `demos` volumes survive the swap unchanged. + +Image tags: + +| Tag | What it points at | +| --- | --- | +| `latest` | The newest published `X.Y.Z` release. | +| `X.Y.Z` | A specific version. Recommended for production. | +| `X.Y` | Floats forward across patch releases of `X.Y.*`. | +| `X` | Floats forward across minor releases of `X.*.*`. | +| `main` | Built from every commit to `main` — bleeding-edge, **not for production**. | +| `sha-` | Pinned to a specific commit on `main`. | + +Every published tag is signed with Sigstore cosign in keyless mode. +Verify the signature with: + +```sh +cosign verify ghcr.io/sbpp/sourcebans-pp:1.7.0 \ + --certificate-identity-regexp='https://github.com/sbpp/sourcebans-pp/.github/workflows/docker-image.yml@.*' \ + --certificate-oidc-issuer='https://token.actions.githubusercontent.com' +``` + +## Docker secrets / `*_FILE` pattern + +Every secret-shaped env var (`SB_SECRET_KEY`, `DB_PASS`, +`DB_ROOT_PASS`, `STEAMAPIKEY`, `INITIAL_ADMIN_PASSWORD`) supports a +sibling `_FILE` form that reads the value from a file path inside +the container. This is the canonical Docker Swarm / Kubernetes +secret-injection pattern. + +A worked Swarm example with two secrets: + +```yaml +# docker-compose.prod.override.yml +services: + web: + environment: + SB_SECRET_KEY_FILE: /run/secrets/sbpp_jwt_key + DB_PASS_FILE: /run/secrets/sbpp_db_pass + secrets: + - sbpp_jwt_key + - sbpp_db_pass +secrets: + sbpp_jwt_key: + file: ./secrets/jwt_key + sbpp_db_pass: + file: ./secrets/db_pass +``` + +The entrypoint prefers the `_FILE` form when both the env var and +the path are set, and silently falls back to the plain env var when +the path isn't present. + +For deployments that mount `config.php` from a secret (rather than +letting the entrypoint render it into the image's writable layer), +set `SBPP_CONFIG_PATH` to the mount path. Both the panel runtime +and the install wizard's already-installed guard read the same +value, so the install-state sentinel stays consistent. + +## Deploy with one click + +This section is reserved for upcoming one-click deploy buttons for +DigitalOcean App Platform, Railway, Render, and Fly.io. Until those +land (tracked in +[issue #1382](https://github.com/sbpp/sourcebans-pp/issues/1382)), +deploy on those platforms by pointing the relevant service at the +`ghcr.io/sbpp/sourcebans-pp:latest` image and setting the same env +vars from `.env.example.prod`. The image already speaks the +platform-conventional knobs: + +- **`PORT`** — the entrypoint rewrites Apache's `Listen` directive + to match. Render / Fly / Heroku-style injection works out of the + box. + + :::caution[Don't set `PORT` on the docker-compose deploy above] + The `docker-compose.prod.yml` shipped in this repo hardcodes + `${SBPP_HOST_PORT}:80` — host port to container port 80. Setting + `PORT=8000` in `.env` would rewrite Apache's listen to `8000` + but compose would still map the host port to the container's + port `80` (which nothing is listening on), making the panel + unreachable. `PORT` is exclusively for the app-platform shape + above; on compose deploys, `SBPP_HOST_PORT` is the host-side knob. + ::: +- **`DATABASE_URL`** — parsed before the split `DB_*` vars get + applied. Railway / Render / Fly's "attached database" pattern + works without extra config. +- **`/health.php`** — DB-aware healthcheck. Point the platform's + health probe at this path. + +## Troubleshooting + +The first place to look is the container's stderr: + +```sh +docker compose -f docker-compose.prod.yml logs --tail=200 web +``` + +Common failures + fixes: + +| Symptom | Likely cause / fix | +| --- | --- | +| Boot stalls at `step 3: waiting for DB`. | The `db` service hasn't passed its healthcheck. `docker compose ... logs db` for the underlying MariaDB error — typically a wrong `DB_ROOT_PASS` on a re-deploy over an existing `dbdata` volume. | +| `step 4: minted fresh SB_SECRET_KEY` on every restart. | You haven't set `SB_SECRET_KEY` in `.env`. The first boot wrote a randomly-generated key into the writable layer's `config.php`, but a container recreation rebuilds the layer fresh. Set the env var to persist. | +| Healthcheck flips to `unhealthy` repeatedly. | `curl https:///health.php` to see the failure reason. If it's a DB connect error, the panel and DB containers have lost their network — `docker compose ... restart` typically resolves. | +| Panel renders but Steam OpenID login fails. | `STEAMAPIKEY` is empty or wrong. Set it in `.env`; bring the panel back with `docker compose ... up -d`. | +| Apache logs `mod_remoteip` warnings about `RemoteIPInternalProxy`. | The `SBPP_TRUSTED_PROXIES` value isn't a valid CIDR. The entrypoint passes the value through verbatim; check for typos. | + +For panel-level errors (login fails after Steam OAuth, "Driver not +found", DB connection issues), the existing troubleshooting pages +apply identically to the Docker install: + + + + + + + +## What's next? + + + + + + diff --git a/web/health.php b/web/health.php new file mode 100644 index 000000000..7cebe9350 --- /dev/null +++ b/web/health.php @@ -0,0 +1,49 @@ +getMessage()`. PDO carries SQLSTATE +// + DB host + internal IP + username + auth mechanism in its +// exception text — leaking that to an unauth caller is a +// reconnaissance gift. Full detail goes to `error_log` for +// the operator instead. +// +// * MED-4: `SBPP_SKIP_TELEMETRY` defined BEFORE init.php so the +// `Telemetry::tickIfDue` shutdown function early-returns +// without the per-probe slot-reservation UPDATE on +// `:prefix_settings.last_ping`. +// +// init.php is still loaded for Sbpp\Db\Database; the entrypoint's +// step-7 strip puts the runtime-guard check on the happy path. + +define('SBPP_SKIP_TELEMETRY', true); +ini_set('display_errors', '0'); +ini_set('html_errors', '0'); + +require_once __DIR__ . '/init.php'; + +header('Content-Type: text/plain; charset=utf-8'); +header('Cache-Control: no-store'); +header('X-Robots-Tag: noindex'); + +try { + $GLOBALS['PDO']->query('SELECT 1'); + $row = $GLOBALS['PDO']->single(); + if (is_array($row) && (int) reset($row) === 1) { + http_response_code(200); + echo "OK\n"; + exit; + } + error_log('[health.php] SELECT 1 returned unexpected shape'); +} catch (\Throwable $e) { + error_log('[health.php] DB probe failed: ' . $e->getMessage()); +} +http_response_code(503); +echo "FAIL\n"; diff --git a/web/includes/Auth/Host.php b/web/includes/Auth/Host.php index 87c1a4eab..3764796b0 100644 --- a/web/includes/Auth/Host.php +++ b/web/includes/Auth/Host.php @@ -26,13 +26,132 @@ public static function protocol(): string return sprintf('http%s://', self::isSecure() ? 's' : ''); } + /** + * Resolve the request scheme. Returns `true` when the panel was + * reached over HTTPS, `false` otherwise. + * + * Resolution order (#1381 CRIT-4): + * + * 1. `$_SERVER['HTTPS'] === 'on'` — the authoritative path. + * On the production Docker image Apache's `mod_remoteip` + * runs first (`RemoteIPInternalProxy` is pinned to the + * operator's `SBPP_TRUSTED_PROXIES` CIDR list) and a + * paired `SetEnvIfExpr` mirrors a trusted upstream's + * `X-Forwarded-Proto: https` into `HTTPS=on`. That keeps + * the trust decision in Apache (the right layer) — by the + * time PHP sees the request, `HTTPS` is the answer the + * panel should trust unconditionally. + * + * 2. `$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'`, ONLY + * if `$_SERVER['REMOTE_ADDR']` matches `SBPP_TRUSTED_PROXIES`. + * This is the fallback for non-Docker deployments where + * the operator never wired up `mod_remoteip` (nginx / + * HAProxy / Traefik with no `X-Forwarded-For` rewrite, + * and PHP-FPM behind it). They `define('SBPP_TRUSTED_PROXIES', + * '10.0.0.0/8 ...')` in `config.php` and the panel honours + * `XFP` only when the immediate caller is one of those + * ranges. + * + * Pre-fix the panel honoured `XFP` unconditionally, which let + * any direct-HTTP attacker spoof `X-Forwarded-Proto: https` + * and trick the panel into setting the `Secure` cookie flag, + * issuing `https://…` redirects, etc. — the panel thought it + * was behind TLS when it was actually serving plaintext to + * the attacker. + * + * `SBPP_TRUSTED_PROXIES` accepts IPv4 + IPv6 literals AND CIDR + * ranges, whitespace-separated (`'10.0.0.0/8 192.168.1.42 ::1'`). + * Empty / undefined disables the XFP fallback entirely (the + * dev container, which talks plain HTTP with no proxy in front, + * hits this branch and returns `false` — its behaviour is + * preserved). + */ public static function isSecure(): bool { - $isHttps = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; - if (!$isHttps) - $isHttps = isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'; + if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { + return true; + } + $xfp = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''; + if (!is_string($xfp) || $xfp !== 'https') { + return false; + } + $remoteAddr = $_SERVER['REMOTE_ADDR'] ?? ''; + return is_string($remoteAddr) + && $remoteAddr !== '' + && self::isTrustedProxy($remoteAddr); + } - return $isHttps; + /** + * Match the immediate-caller IP against the operator-defined + * `SBPP_TRUSTED_PROXIES` list. Empty / undefined trust list + * returns `false` — the secure default. + * + * Supports IPv4 + IPv6 literals AND CIDR ranges. Whitespace + * separates entries so the same string is hand-editable in + * `config.php` (`define('SBPP_TRUSTED_PROXIES', '10.0.0.0/8 + * 192.168.0.0/16');`) and machine-friendly as an env var + * (`SBPP_TRUSTED_PROXIES="10.0.0.0/8 192.168.0.0/16"`). + */ + public static function isTrustedProxy(string $ip): bool + { + if (!defined('SBPP_TRUSTED_PROXIES')) { + return false; + } + $list = (string) constant('SBPP_TRUSTED_PROXIES'); + if (trim($list) === '') { + return false; + } + $entries = preg_split('/\s+/', trim($list), -1, PREG_SPLIT_NO_EMPTY) ?: []; + foreach ($entries as $entry) { + if (self::ipMatchesRange($ip, $entry)) { + return true; + } + } + return false; + } + + /** + * Compare an IP against a single trust-list entry. The entry + * may be a literal (`'10.0.0.5'` / `'::1'`) or CIDR notation + * (`'10.0.0.0/8'` / `'2001:db8::/32'`). Uses `inet_pton` for + * the binary mask compare so IPv4 + IPv6 share one code path. + */ + private static function ipMatchesRange(string $ip, string $entry): bool + { + $entry = trim($entry); + if ($entry === '') { + return false; + } + $ipBin = @inet_pton($ip); + if ($ipBin === false) { + return false; + } + if (!str_contains($entry, '/')) { + $entryBin = @inet_pton($entry); + return $entryBin !== false && $ipBin === $entryBin; + } + [$range, $bits] = explode('/', $entry, 2); + $rangeBin = @inet_pton($range); + if ($rangeBin === false) { + return false; + } + if (strlen($ipBin) !== strlen($rangeBin)) { + return false; // address family mismatch (IPv4 vs IPv6) + } + $bits = (int) $bits; + if ($bits < 0 || $bits > strlen($ipBin) * 8) { + return false; + } + $fullBytes = intdiv($bits, 8); + $remainderBits = $bits % 8; + if ($fullBytes > 0 && substr($ipBin, 0, $fullBytes) !== substr($rangeBin, 0, $fullBytes)) { + return false; + } + if ($remainderBits === 0) { + return true; + } + $mask = chr(0xff << (8 - $remainderBits) & 0xff); + return (ord($ipBin[$fullBytes]) & ord($mask)) === (ord($rangeBin[$fullBytes]) & ord($mask)); } /** diff --git a/web/includes/Telemetry/Telemetry.php b/web/includes/Telemetry/Telemetry.php index ca85e5581..75f878ee9 100644 --- a/web/includes/Telemetry/Telemetry.php +++ b/web/includes/Telemetry/Telemetry.php @@ -87,9 +87,21 @@ final class Telemetry * defensive (early-return on opt-out, atomic reservation, * silent cURL), but the outer `try/catch` is the * never-fail-the-request guarantee. + * + * Callers that want to opt OUT of telemetry for a single + * request (e.g. the `/health.php` probe, which the orchestrator + * hits every ~30s — MED-4 of the #1381 review) `define` + * `SBPP_SKIP_TELEMETRY` before requiring `init.php`. The + * rate-limit guard further down would already prevent a real + * cURL POST on every probe, but the per-tick slot-reservation + * `UPDATE` on `:prefix_settings.last_ping` is still pure noise + * we'd rather not pay on a 2880-probe-per-day endpoint. */ public static function tickIfDue(): void { + if (defined('SBPP_SKIP_TELEMETRY')) { + return; + } try { self::run(); } catch (Throwable) { diff --git a/web/index.php b/web/index.php index f972dc6fa..ed9e290d0 100644 --- a/web/index.php +++ b/web/index.php @@ -5,7 +5,12 @@ include_once 'init.php'; include_once(INCLUDES_PATH . "/system-functions.php"); -include_once('config.php'); +// HIGH-1 of the #1381 review: do NOT include `config.php` here. +// `init.php` already loaded it via `sbpp_resolve_config_path()` +// (which honours `SBPP_CONFIG_PATH` for Docker-secret mounts); +// a second literal `include_once('config.php')` would either +// fail (no `config.php` at `web/`) or shadow the secret-mounted +// values with a stale on-disk copy. include_once(INCLUDES_PATH . "/page-builder.php"); $route = route(Config::get('config.defaultpage')); diff --git a/web/init-recovery.php b/web/init-recovery.php index ac5163507..b1ae4b5a1 100644 --- a/web/init-recovery.php +++ b/web/init-recovery.php @@ -29,6 +29,44 @@ // the function testable without spinning up a process — the tests // just assert the verdict, not the rendered HTML. +/** + * Resolve the runtime path of `config.php`. + * + * Default: `/config.php` (same path the install wizard + * writes to and `web/init.php` historically required). Operators + * deploying via the production Docker image (#1381) can set the + * `SBPP_CONFIG_PATH` env var to point at a path outside the + * read-only image layer — e.g. a Docker secret mounted at + * `/run/secrets/sbpp-config.php`. The entrypoint reads the same env + * var when rendering config.php on first boot, so the two halves + * agree on where the file lives. + * + * Env-var-driven (not constant-driven) because the lookup runs + * BEFORE config.php is loaded — and the recovery surface (this + * file) deliberately has zero Composer dependencies, so we can't + * reach for `Sbpp\…` config plumbing here. `getenv()` is the + * portable, dependency-free way to read deployment env without + * also pulling in `$_ENV` / `$_SERVER` polluted-superglobal + * concerns. + * + * Self-hoster contract: setting `SBPP_CONFIG_PATH` in the panel's + * environment ALSO requires the wizard / entrypoint to write to the + * same path. The default-tarball install path doesn't set the env + * var, so there's no confusion for the 99% of self-hosters who + * don't customise this. + * + * @param string $defaultPath The fallback path (typically `ROOT . 'config.php'`). + * @return string Absolute path to config.php. + */ +function sbpp_resolve_config_path(string $defaultPath): string +{ + $envPath = getenv('SBPP_CONFIG_PATH'); + if (is_string($envPath) && $envPath !== '') { + return $envPath; + } + return $defaultPath; +} + /** * Decide whether the panel runtime should refuse to boot. * diff --git a/web/init.php b/web/init.php index ecb4ef46f..dd976e34b 100644 --- a/web/init.php +++ b/web/init.php @@ -47,7 +47,15 @@ require_once(ROOT.'/init-recovery.php'); #DB Config -if (!file_exists(ROOT.'/config.php')) { +// +// `SBPP_CONFIG_PATH` env var (#1381 deliverable 4d): the production +// Docker image lets operators mount config.php from a Docker secret +// path outside the read-only image layer. Falls back to the legacy +// `/config.php` for tarball / wizard installs that don't +// set the var. See sbpp_resolve_config_path() in init-recovery.php +// for the contract. +$sbppConfigPath = sbpp_resolve_config_path(ROOT . 'config.php'); +if (!file_exists($sbppConfigPath)) { // M1 bonus: redirect to /install/ instead of a bare-text die. // The wizard is the actionable next step for a panel without // config.php, so dropping the operator there is strictly more @@ -58,7 +66,28 @@ header('Location: install/'); exit; } -require_once(ROOT.'/config.php'); +require_once($sbppConfigPath); + +// SBPP_TRUSTED_PROXIES (#1381 CRIT-4): operator-defined list of +// CIDR ranges whose `X-Forwarded-Proto` / `X-Forwarded-For` the +// panel will trust. Format: whitespace-separated list of IP +// literals or CIDR ranges (IPv4 + IPv6), e.g. +// `'10.0.0.0/8 192.168.0.0/16 ::1'`. Empty / undefined disables +// XFP / XFF consultation entirely. +// +// Resolution order: +// 1. `config.php` `define('SBPP_TRUSTED_PROXIES', ...)` — wins. +// 2. `SBPP_TRUSTED_PROXIES` env var (the Docker prod image +// exports this in `configure_apache`). +// 3. Empty string — the secure default; `Host::isSecure()` +// ignores `X-Forwarded-Proto` and trusts only the +// authoritative `$_SERVER['HTTPS']` (which `mod_remoteip` +// + `SetEnvIfExpr` populate server-side after the proxy +// hop is validated by Apache). +if (!defined('SBPP_TRUSTED_PROXIES')) { + $envProxies = getenv('SBPP_TRUSTED_PROXIES'); + define('SBPP_TRUSTED_PROXIES', is_string($envProxies) ? $envProxies : ''); +} // Issue #1335 C1: pre-fix this guard exempted `HTTP_HOST == // "localhost"`, which was a panel-takeover path on any panel diff --git a/web/install/already-installed.php b/web/install/already-installed.php index b9114ebbe..a083d1dba 100644 --- a/web/install/already-installed.php +++ b/web/install/already-installed.php @@ -49,7 +49,24 @@ */ function sbpp_install_is_already_installed(string $panelRoot): bool { - return file_exists($panelRoot . 'config.php'); + // #1381 deliverable 4d: when the production Docker image is + // configured with `SBPP_CONFIG_PATH=/run/secrets/...` (or any + // other path outside the panel root), the wizard's + // already-installed guard must check the SAME path that + // `web/init.php`'s loader checks — otherwise an operator + // running the production image with a Docker-secret-mounted + // config could still hit the wizard with their panel intact. + // + // We deliberately re-implement the env-var read inline (rather + // than `require_once`-ing init-recovery.php) to keep this file + // self-contained per its docblock — the C2 guard runs upstream + // of Composer autoload + Smarty for the same defensiveness + // reasons as `recovery.php`. + $envPath = getenv('SBPP_CONFIG_PATH'); + $configPath = (is_string($envPath) && $envPath !== '') + ? $envPath + : $panelRoot . 'config.php'; + return file_exists($configPath); } /** diff --git a/web/install/bootstrap.php b/web/install/bootstrap.php index 8baf39e5c..8b2025ec7 100644 --- a/web/install/bootstrap.php +++ b/web/install/bootstrap.php @@ -38,6 +38,16 @@ // 2 onward instantiates one against the operator-supplied creds. require_once PANEL_INCLUDES_PATH . '/Db/Database.php'; +// HIGH-2 of the #1381 review: page 5 writes the final config.php and +// MUST honour `SBPP_CONFIG_PATH` so an operator running the wizard +// manually (e.g. `SBPP_AUTO_INSTALL=0` on the prod Docker image with +// a Docker-secret-mounted config) lands the file at the path the +// panel runtime will actually read at boot. `sbpp_resolve_config_path` +// is the shared helper in `web/init-recovery.php` — pure PHP, zero +// Composer dependencies — so we can pull it in without disturbing +// the wizard's "no Sbpp\… chrome" contract. +require_once PANEL_ROOT . 'init-recovery.php'; + // Shared step-handler helpers (prefix validation, DB-open with raw // PDO probe, KeyValues escaping). Required after Database.php so // sbpp_install_open_db() can reference \Database. See helpers.php diff --git a/web/install/pages/page.5.php b/web/install/pages/page.5.php index b5ab1494d..182dab34c 100644 --- a/web/install/pages/page.5.php +++ b/web/install/pages/page.5.php @@ -112,13 +112,30 @@ } // 3) Build + write config.php (best-effort). + // + // HIGH-2 of the #1381 review: pre-fix the wizard wrote + // verbatim to `PANEL_ROOT . 'config.php'`, ignoring the + // `SBPP_CONFIG_PATH` env var the prod entrypoint sets + // for Docker-secret mounts. An operator running the + // wizard manually (`SBPP_AUTO_INSTALL=0`) with + // `SBPP_CONFIG_PATH=/run/secrets/sbpp-config` would see + // the wizard's "config.php written successfully" green + // light, restart the panel, and faceplant because the + // panel reads from the secret path while the wizard + // wrote to the writable layer. + // + // `sbpp_resolve_config_path()` is the helper added in + // #1381 deliverable 4d (live at `web/init-recovery.php`); + // it honours the env var with a `PANEL_ROOT . 'config.php'` + // fallback so non-Docker installs are unchanged. $configText = sbpp_install_render_config( $server, $port, $username, $password, $database, $prefix, $charset, $apikey, $sbEmail ); - $configPath = PANEL_ROOT . 'config.php'; + $configPath = sbpp_resolve_config_path(PANEL_ROOT . 'config.php'); + $configDir = dirname($configPath); $configWritable = is_writable($configPath) - || (is_writable(PANEL_ROOT) && !file_exists($configPath)); + || (is_dir($configDir) && is_writable($configDir) && !file_exists($configPath)); if ($configWritable) { file_put_contents($configPath, $configText); } diff --git a/web/phpstan-baseline.neon b/web/phpstan-baseline.neon index 773c860a0..92a413a2f 100644 --- a/web/phpstan-baseline.neon +++ b/web/phpstan-baseline.neon @@ -156,12 +156,6 @@ parameters: count: 1 path: includes/page-builder.php - - - message: '#^Path in include_once\(\) "config\.php" is not a file or it does not exist\.$#' - identifier: includeOnce.fileNotFound - count: 1 - path: index.php - - message: '#^Constant SB_THEME not found\.$#' identifier: constant.notFound diff --git a/web/tests/integration/InstallGuardTest.php b/web/tests/integration/InstallGuardTest.php index 4cbbdd00b..4fc8d8ad1 100644 --- a/web/tests/integration/InstallGuardTest.php +++ b/web/tests/integration/InstallGuardTest.php @@ -274,6 +274,91 @@ public function testWizardRefusesToStartOverInstalledPanel(): void } } + /** + * Issue #1381 deliverable 4d: production Docker deploys can mount + * config.php from a path outside the read-only image layer (Docker + * secret, k8s-mounted secret, etc.) by setting the + * `SBPP_CONFIG_PATH` env var. Both halves of the panel — the + * runtime loader (`init.php`) and the wizard's already-installed + * guard (`already-installed.php`) — must honour the same env var + * so the install-state sentinel is checked at the right path. + * + * Pre-#1381 both halves hard-coded the panel-root path. With a + * Docker-secret-mounted config, the runtime loader would fail to + * find config.php (and redirect to /install/), while the wizard + * would happily start over and overwrite the panel's setup. + * + * The shared helper `sbpp_resolve_config_path()` lives in + * `init-recovery.php` and is the single source of truth for the + * env-var read; the wizard's + * `sbpp_install_is_already_installed()` re-implements the same + * check inline (the wizard's pre-vendor stage deliberately can't + * reach for `init-recovery.php` per its self-contained docblock). + */ + public function testResolveConfigPathHonorsEnvVar(): void + { + $this->loadInitRecovery(); + + // Default: env var unset, returns the fallback path. + putenv('SBPP_CONFIG_PATH'); + $this->assertSame( + '/some/panel/config.php', + sbpp_resolve_config_path('/some/panel/config.php'), + 'sbpp_resolve_config_path() must return the default path when ' . + 'SBPP_CONFIG_PATH is unset (the tarball / wizard install path).' + ); + + // Set: returns the env value, ignoring the default. + putenv('SBPP_CONFIG_PATH=/run/secrets/sbpp-config.php'); + try { + $this->assertSame( + '/run/secrets/sbpp-config.php', + sbpp_resolve_config_path('/some/panel/config.php'), + 'sbpp_resolve_config_path() must honour SBPP_CONFIG_PATH for ' . + 'production Docker deploys that mount config.php from a Docker ' . + 'secret path outside the read-only image layer.' + ); + } finally { + putenv('SBPP_CONFIG_PATH'); + } + } + + /** + * Sister-test to {@see testResolveConfigPathHonorsEnvVar}: the + * wizard's already-installed guard must read the same env var + * inline (the wizard's pre-vendor stage can't reach + * init-recovery.php per its self-contained docblock). + */ + public function testWizardGuardHonorsConfigPathEnvVar(): void + { + $this->loadAlreadyInstalled(); + $tmp = $this->makeTempPanelRoot(); + $configPath = $tmp['root'] . '/secret-config.php'; + try { + // No env var, no default-path config.php — guard returns false. + putenv('SBPP_CONFIG_PATH'); + $this->assertFalse( + sbpp_install_is_already_installed($tmp['root'] . '/'), + 'No config.php anywhere — wizard guard should not fire.' + ); + + // Custom path, file exists at custom path: guard fires. + putenv('SBPP_CONFIG_PATH=' . $configPath); + file_put_contents($configPath, 'assertTrue( + sbpp_install_is_already_installed($tmp['root'] . '/'), + '#1381 regression: the wizard guard must honour SBPP_CONFIG_PATH ' . + 'so a panel deployed via the production Docker image (with config ' . + 'mounted from a Docker secret outside the panel root) cannot be ' . + 'wizard-attacked just because the default panel-root path is empty.' + ); + } finally { + putenv('SBPP_CONFIG_PATH'); + @unlink($configPath); + $this->rmTempRoot($tmp['root']); + } + } + /** * Issue #1335 m4: the step-2 PDOException translator emits * friendlier messages for the connect-error codes diff --git a/web/tests/integration/TelemetryOptOutTest.php b/web/tests/integration/TelemetryOptOutTest.php index 2e0b1adf4..3a9ced930 100644 --- a/web/tests/integration/TelemetryOptOutTest.php +++ b/web/tests/integration/TelemetryOptOutTest.php @@ -4,6 +4,7 @@ namespace Sbpp\Tests\Integration; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use Sbpp\Config; use Sbpp\Telemetry\Telemetry; use Sbpp\Tests\ApiTestCase; @@ -100,6 +101,41 @@ public function testTickIfDueIsNoopInsideCooldown(): void ); } + /** + * MED-4 of the #1381 review — `/health.php` defines + * `SBPP_SKIP_TELEMETRY` BEFORE `require_once 'init.php'`, so + * the shutdown function `init.php` registers + * (`Telemetry::tickIfDue`) early-returns without touching the + * `:prefix_settings.last_ping` reservation row. The + * orchestrator hits the healthcheck every ~30s in production, + * so the per-tick `UPDATE` would otherwise be ~2880 writes/day + * of pure noise. + * + * Process-isolated because `define()` is process-global; once + * set, the rest of the suite would silently inherit the + * opt-out and the other tests in this class would lose their + * gate. + */ + #[RunInSeparateProcess] + public function testTickIfDueShortCircuitsWhenSbppSkipTelemetryDefined(): void + { + $this->setTelemetryEnabled(true); + define('SBPP_SKIP_TELEMETRY', true); + + Telemetry::tickIfDue(); + + $this->assertSame( + '0', + $this->fetchSetting('telemetry.last_ping'), + 'SBPP_SKIP_TELEMETRY must short-circuit tickIfDue before any DB write.' + ); + $this->assertSame( + '', + $this->fetchSetting('telemetry.instance_id'), + 'SBPP_SKIP_TELEMETRY must short-circuit tickIfDue before the instance ID mint.' + ); + } + private function setTelemetryEnabled(bool $on): void { $this->setSetting('telemetry.enabled', $on ? '1' : '0'); diff --git a/web/tests/unit/Auth/HostIsSecureTest.php b/web/tests/unit/Auth/HostIsSecureTest.php new file mode 100644 index 000000000..4f4c5f3e6 --- /dev/null +++ b/web/tests/unit/Auth/HostIsSecureTest.php @@ -0,0 +1,211 @@ +assertTrue(Host::isSecure()); + } + + public function testNoHttpsNoXfpReturnsFalse(): void + { + $this->assertFalse(Host::isSecure()); + } + + public function testHttpsOffWithoutXfpReturnsFalse(): void + { + $_SERVER['HTTPS'] = 'off'; + + $this->assertFalse(Host::isSecure()); + } + + /** + * Attacker case — direct-HTTP hit with a spoofed + * `X-Forwarded-Proto: https` and no proxy trust configured. + * Pre-fix this returned `true` (the bug). Post-fix it must + * return `false`. + */ + #[RunInSeparateProcess] + public function testXfpFromUntrustedSourceWithoutTrustListReturnsFalse(): void + { + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = '203.0.113.5'; + + $this->assertFalse(Host::isSecure()); + } + + /** + * Trust list defined but the attacker's REMOTE_ADDR is + * outside it — XFP still rejected. + */ + #[RunInSeparateProcess] + public function testXfpFromIpOutsideTrustListReturnsFalse(): void + { + define('SBPP_TRUSTED_PROXIES', '10.0.0.0/8'); + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = '203.0.113.5'; + + $this->assertFalse(Host::isSecure()); + } + + /** + * Trusted-proxy case — REMOTE_ADDR is inside the configured + * trust list AND XFP is `https`. Returns `true`. + */ + #[RunInSeparateProcess] + public function testXfpFromTrustedProxyReturnsTrue(): void + { + define('SBPP_TRUSTED_PROXIES', '10.0.0.0/8 192.168.0.0/16'); + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = '10.0.5.13'; + + $this->assertTrue(Host::isSecure()); + } + + /** + * Trust list entry that's a literal IP, not a CIDR — exact + * match path. + */ + #[RunInSeparateProcess] + public function testXfpFromTrustedLiteralIpReturnsTrue(): void + { + define('SBPP_TRUSTED_PROXIES', '192.0.2.100'); + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = '192.0.2.100'; + + $this->assertTrue(Host::isSecure()); + } + + /** + * IPv6 CIDR — REMOTE_ADDR inside the range. + */ + #[RunInSeparateProcess] + public function testXfpFromTrustedIpv6CidrReturnsTrue(): void + { + define('SBPP_TRUSTED_PROXIES', 'fd00::/8'); + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = 'fd12:3456:789a::1'; + + $this->assertTrue(Host::isSecure()); + } + + /** + * IPv6 CIDR — REMOTE_ADDR outside the range. + */ + #[RunInSeparateProcess] + public function testXfpFromIpv6OutsideTrustedCidrReturnsFalse(): void + { + define('SBPP_TRUSTED_PROXIES', 'fd00::/8'); + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = '2001:db8::1'; + + $this->assertFalse(Host::isSecure()); + } + + /** + * Family mismatch — IPv4 IP against an IPv6 CIDR (and vice + * versa) must NOT match. Pre-fix a naive `inet_pton` compare + * could silently match across families if the binary + * representations happened to share a prefix. + */ + #[RunInSeparateProcess] + public function testIpv4AgainstIpv6CidrDoesNotMatch(): void + { + define('SBPP_TRUSTED_PROXIES', 'fd00::/8'); + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = '10.0.5.13'; + + $this->assertFalse(Host::isSecure()); + } + + /** + * Trust list defined but empty (operator unset it via env). + * XFP must NOT be honoured. + */ + #[RunInSeparateProcess] + public function testEmptyTrustListDoesNotHonourXfp(): void + { + define('SBPP_TRUSTED_PROXIES', ''); + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = '10.0.5.13'; + + $this->assertFalse(Host::isSecure()); + } + + /** + * Whitespace-only trust list — same as empty. + */ + #[RunInSeparateProcess] + public function testWhitespaceOnlyTrustListDoesNotHonourXfp(): void + { + define('SBPP_TRUSTED_PROXIES', " \n\t "); + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REMOTE_ADDR'] = '10.0.5.13'; + + $this->assertFalse(Host::isSecure()); + } + + /** + * `HTTPS=on` wins regardless of a missing / spoofed XFP — + * the production-Docker happy path where `mod_remoteip` + + * `SetEnvIfExpr` already mirrored the upstream scheme. + */ + #[RunInSeparateProcess] + public function testHttpsOnWinsRegardlessOfTrustList(): void + { + $_SERVER['HTTPS'] = 'on'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http'; + $_SERVER['REMOTE_ADDR'] = '203.0.113.5'; + + $this->assertTrue(Host::isSecure()); + } +}