feat: production Docker image + compose for self-hosters (#1381)#1391
Merged
Conversation
…1381) Adds `sbpp_resolve_config_path()` to `init-recovery.php` so the panel runtime + the install wizard read config.php from the same path. When unset, both halves fall back to the legacy `<panel-root>/config.php` location (tarball / wizard installs continue to work unchanged). This unblocks the production Docker image (#1381 deliverable 4d): operators can mount config.php from a Docker secret at e.g. `/run/secrets/sbpp-config.php` and set `SBPP_CONFIG_PATH` so both the runtime loader and the wizard's already-installed guard check the mounted path, instead of the read-only image layer. `web/install/already-installed.php` re-implements the env-var read inline (rather than reaching for `init-recovery.php`) per its self-contained "no Sbpp\…, no Smarty, no vendor/" docblock — the C2 guard runs upstream of Composer autoload for the same defensiveness reasons as `recovery.php`. Tests: - `testResolveConfigPathHonorsEnvVar` pins the helper's behaviour (unset → default; set → env value wins). - `testWizardGuardHonorsConfigPathEnvVar` pins the wizard-side contract so the panel-takeover surface stays closed when config lives outside the panel root.
Deliverables 1, 2, 5b from #1381. `docker/Dockerfile.prod` — multi-stage build separate from the dev `docker/Dockerfile`: - `builder` stage on `php:8.5-cli` + `composer:2` runs the same `composer install --no-dev --optimize-autoloader --prefer-dist` shape as `release.yml` so the image ships byte-identical vendor/ to the release tarball. - `runtime` stage on `php:8.5-apache` carries ONLY runtime extensions (pdo_mysql, intl, zip, mbstring, gmp) — no nodejs / npm / git / composer / dev-prepend. - Multi-arch-friendly (no `--platform=...` pins; workflow drives linux/amd64 + linux/arm64 via buildx + qemu). - Production OPcache: validate_timestamps=0, ~6k accelerated files. - HEALTHCHECK via curl against /health.php. - OCI labels for GHCR auto-population + `docker inspect` provenance. `docker/php/prod-entrypoint.sh` — pure POSIX shell state machine (no bash-isms). Drives: - *_FILE secret resolution (Docker Swarm + k8s). - DATABASE_URL parsing (Render / Fly / Railway-style). - PORT injection (Render / Fly / Heroku-style — rewrites /etc/apache2/ports.conf + the vhost). - mod_remoteip via SBPP_TRUSTED_PROXIES (defaults to "no trust" so plain Docker isn't accidentally trust-everyone; the conf also mirrors X-Forwarded-Proto -> $_SERVER['HTTPS'] for the legacy PHP-side checks via SetEnvIfExpr). - Wait-for-DB via mysqladmin ping (60s ceiling). - config.php render (only when missing/empty — config.php is the install-state sentinel per AGENTS.md, never auto-regenerated). - First-boot install: pipes struc.sql + data.sql with {prefix} / {charset} substituted, seeds initial admin from INITIAL_ADMIN_{NAME,STEAM,EMAIL,PASSWORD} env vars. - Pending migrations: drives the headless Updater via PHP heredoc (each `web/updater/data/<N>.php` is idempotent per the AGENTS.md contract, so re-running on every container start is safe). - Strips install/ + updater/ from the writable layer so the panel-runtime guard in init-recovery.php passes — production panels MUST NOT define SBPP_DEV_KEEP_INSTALL. - Ensures writable cache/ + templates_c/ + demos/. - exec apache2-foreground. `docker/php/prod-php.ini` — 12-factor error config (display_errors Off, log_errors On, errors to /dev/stderr), expose_php Off, OPcache production knobs, UTC timezone default. `docker/apache/sbpp-prod.conf` — denies access to dotfiles, vendor/, configs/, includes/, install/, updater/, cache/, templates_c/, config.php, composer.{json,lock}. RemoteIPHeader X-Forwarded-For declared here so the entrypoint's per-deploy conf only has to add `RemoteIPInternalProxy` lines. `web/health.php` — unauthenticated DB-aware healthcheck. Loads init.php (the authoritative bootstrap; the entrypoint's step 7 removes install/+updater/ before Apache binds so the guard passes by the time this file serves traffic) and runs `SELECT 1` via `$GLOBALS['PDO']`. Returns 200 OK on success, 503 + plain-text failure reason otherwise. Cache-Control: no-store, X-Robots-Tag: noindex.
Deliverable 4 from #1381. `docker-compose.prod.yml` lives alongside the dev compose (not replacing it) so self-hosters with the repo checked out can pick either one. Shape: - `web` service pulls `ghcr.io/sbpp/sourcebans-pp:${SBPP_IMAGE_TAG:-latest}` (NOT a build context — operators run `docker compose pull` to upgrade, not a local build). - `db` service is mariadb:11 LTS with a `dbdata` volume + healthcheck. The DB port is NOT exposed to the host by default — the web container reaches the DB via the compose network's service-name DNS. A commented block shows operators how to opt into a localhost-only host binding for tooling access. - Three named volumes per the spec: - `dbdata` — MUST persist (panel's only source of truth for ban / admin / log rows). - `demos` — uploaded ban-evidence demos; MUST persist (admin user content, not regenerable). - `cache` + `smarty` — Smarty compile + sessions; CAN be ephemeral (panel rebuilds on first request). - Commented `caddy` service stub at the bottom: uncomment to opt into automatic Let's Encrypt TLS termination via the Caddyfile.example shipped under docker/caddy/. The stub uses the service-name DNS so the panel container doesn't need a host-port exposure when Caddy is in front. `.env.example.prod` documents every supported env var grouped by required / recommended / first-boot-install / optional / advanced. Calls out the `*_FILE` Docker-secret pattern for Swarm/k8s deploys explicitly. Required vars (SB_SECRET_KEY, DB_PASS, DB_ROOT_PASS) use the `${VAR:?...}` "fail with this message" compose syntax so a fresh-deploy operator who forgot to set them gets a useful container-startup error instead of a silent JWT-cookie reset on every restart. `docker/caddy/Caddyfile.example` — one-line reverse_proxy to the `web` service plus standard Caddy goodies (zstd/gzip encode, a year-long cache header for static assets). Documented to be paired with `SBPP_TRUSTED_PROXIES=172.16.0.0/12 10.0.0.0/8` so the panel trusts Caddy's X-Forwarded-* headers from the Docker bridge range.
Deliverable 3 from #1381. `.github/workflows/docker-image.yml` builds `docker/Dockerfile.prod` for linux/amd64 + linux/arm64 via buildx + qemu, publishes to GHCR under `ghcr.io/sbpp/sourcebans-pp:<tags>`, and signs each tag with Sigstore cosign in keyless (OIDC) mode. Triggers + tag mapping: - push to main → :main, :sha-<short> - X.Y.Z tag → :X.Y.Z, :X.Y, :X, :latest - pull_request → verify-build-only (no push, no sign) - workflow_dispatch → manual rerun (same shape as main push) The `:latest` tag is gated on `type=semver,pattern={{version}}` + `enable=${{ startsWith(github.ref, 'refs/tags/') }}` so main pushes can't accidentally claim it; only formal X.Y.Z tags do. Path filter scope is intentionally narrow — Dockerfile.prod, the docker/ assets it touches, composer.{json,lock} for vendor churn, the panel files the entrypoint relies on (init.php / init-recovery.php / health.php), and the workflow file itself. Every web/** edit doesn't rebuild the prod image; that would burn action minutes for surface changes the next release tag picks up anyway. Main and tag pushes always rebuild. Cosign keyless signing requires `id-token: write` at the job permissions level. Each computed tag gets its own signature against the immutable digest (`<image>@<digest>`) so a future re-tag doesn't invalidate the signature. Verifiers can pin identity / issuer: 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' Caches via `type=gha,mode=max` so a Composer-only change doesn't bust the apt-install layer of the builder stage.
Deliverable 5 from #1381 and the paired AGENTS.md / docker README sync. New doc: - `docs/src/content/docs/getting-started/quickstart-docker.mdx` — walks a self-hoster from `mkdir ~/sourcebans-prod` to "logged in as admin". Covers env-var configuration, the *_FILE Docker-secret pattern, persistent-volume layout, Caddy reverse-proxy setup, the cosign-verify command, upgrading by tag bump, and a troubleshooting table for the common boot-time failures. Reserves a "Deploy with one click" section pointing at #1382 (the app-platform buttons follow-up). Updated docs: - `overview.mdx` — adds a `<Tabs syncKey="install-path">` block with three arms (tarball / docker / app-platform) so the install path is the first decision a new self-hoster makes. The "Things you'll set up" list defers the Step 2 instructions to whichever path the operator picks. - `prerequisites.mdx` — adds the same Tabs at the top so Docker operators see "Docker 24+, Compose v2, 512MB RAM" instead of "PHP 8.5+, four extensions" which doesn't apply to them. The PHP / DB sections stay for the tarball path. - `astro.config.mjs` — adds the new quickstart-docker page to the Getting Started sidebar group, ordered after the tarball quickstart. AGENTS.md changes: - Quality gates table: new "Prod Docker image" row pointing at the local `docker buildx build` command + `docker-image.yml` workflow. Bumped the header from "six gates" to "seven gates". - New "Prod Docker image specifics" block (paired with the existing "Plugin build specifics" / "ts-check specifics" / "PHPStan specifics" blocks). Documents the path-filter scope, multi-arch via qemu, PR-builds-verify-only contract, tag mapping logic, cosign keyless signing requirements, and the "no local ./sbpp.sh wrapper" decision. - "Where to find what" table: three new rows for the prod image / compose stack / SBPP_CONFIG_PATH plumbing. - "Keep the docs in sync" table: the install-quickstart row now points at BOTH `quickstart.mdx` and `quickstart-docker.mdx` with the per-path note. New row pinning that any change to the prod Docker cluster ships paired updates to AGENTS.md (quality gates + where-to-find) + the operator-facing docs + docker/README.md. `docker/README.md` — adds a "Dev or prod?" pointer block at the top so anyone landing on the dev README to learn about Docker discovers the prod path exists. Explains the dangerous dev-only behaviours (admin/admin, bind-mounted worktree, SBPP_DEV_KEEP_INSTALL, DB port exposed) so the read-cost-vs- take-this-to-production trade-off is obvious.
…016 in prod image (#1381) Lint pass over the new prod docker assets after running the upstream linters in CI-equivalent containers: - `docker/php/prod-entrypoint.sh`: `resolve_file_secret`'s `export "$name"` is correct POSIX shell, but shellcheck SC2163 fires on the deferred expansion. Swap to the documented `${var?}` silencing shape, which also gates against `$name` being unset — strictly tighter behaviour than the bare form. - `docker/php/prod-entrypoint.sh`: the `php -r 'echo password_hash(\$argv[1], …)'` call uses single quotes intentionally so the shell doesn't rewrite the literal `\$argv` before php sees it. Mark the line with `# shellcheck disable=SC2016` + the rationale instead of converting to double quotes (which would either need shell escaping or risk variable expansion of `\$argv`). - `docker/Dockerfile.prod`: add `# hadolint ignore=DL3008` to the three `apt-get install` blocks. Debian-base apt packages don't pin cleanly across releases; the project pins the BASE IMAGE tag (`php:8.5-cli`) instead, which gives a reproducible Debian snapshot. Pinning individual apt packages would force a coupled bump every time the base image rolls forward. - `docker/Dockerfile.prod`: add `# hadolint ignore=SC2016` to the sed block that rewrites `/var/www/html` -> `\${APACHE_DOCUMENT_ROOT}` in Apache's vhost configs. The literal `\${APACHE_DOCUMENT_ROOT}` is resolved at Apache STARTUP TIME via Apache's own env-var support, not by the shell — letting the shell pre-expand would bake the build-time value in and lose the runtime-overridability of the env var. Also rewrite the `gmp` comment in the runtime stage: the original attributed `gmp` to `Sbpp\\SteamID` math, but that class uses native PHP integer arithmetic. The actual justifications are (a) the spec'd runtime-extension list in #1381 deliverable 1 and (b) the `maxmind-db/reader` soft-dependency for the pure-PHP MMDB integer decoding path. All four linters (shellcheck, hadolint, actionlint, `docker compose config`) now report clean on the prod-side files. The dev `docker/Dockerfile` carries the same DL3008 + SC2016 patterns but is intentionally out of scope here — touching it would balloon the PR.
…IGH-3/5, MED-1/2/3/5, LOW-1, NIT-2) Address ten findings from the #1381 adversarial review by reworking `docker/php/prod-entrypoint.sh` and introducing the bundled PHP CLI helper `docker/php/sb-db.php`. * CRIT-1 — `parse_database_url` now URL-decodes the user / password fields via `php -r 'echo urldecode(...);'` instead of the previous `sed | printf '%b'` hack, which was a silent no-op on Debian's /bin/sh (dash). Passwords carrying any URL-reserved character (`@`, `:`, `/`, `?`, `#`, `&`, `%`, `+`, space) survive the round-trip now. Verified `us%40er` → `us@er` and `p%40ss%23word` → `p@ss#word` against `php:8.5-apache`. * CRIT-2 — `main()` unsets `INITIAL_ADMIN_*`, `INITIAL_ADMIN_*_FILE`, `DB_PASS`, `DB_PASS_FILE`, `SB_SECRET_KEY`, `SB_SECRET_KEY_FILE`, `STEAMAPIKEY`, `STEAMAPIKEY_FILE`, and `DATABASE_URL` immediately before `exec "$@"`. Apache children would otherwise inherit cleartext credentials reachable via `$_ENV` / `phpinfo()` / `getenv()` for the lifetime of the container. * CRIT-5 — `run_pending_migrations` inspects the `Updater` message stack for failure markers (`Error executing:`, `Update Failed!`) after each pass and `exit(1)`s if any are found, so the shell's `if [ "$rc" -ne 0 ]` is no longer dead code. A failing migration now aborts the boot loop instead of silently leaving the panel on a half-applied schema. * CRIT-6 — New `validate_identifiers` stage runs between `apply_defaults` and `configure_apache`. It rejects `DB_PREFIX` / `DB_NAME` / `DB_USER` / `DB_CHARSET` (`[A-Za-z0-9_]+`), `DB_HOST` (`[A-Za-z0-9._-]+`), and `DB_PORT` (`[0-9]+`) before any sed substitution or SQL interpolation, closing the `DB_PREFIX="foo'; DROP --"` class of bug. * HIGH-3 — `wait_for_db` and `run_sql` no longer call `mysqladmin` / `mysql`; both go through the new `sb-db.php` PDO helper. Pairs with the Dockerfile.prod change in a follow-up commit that drops `default-mysql-client` from the runtime apt layer (~22MB image size win) — the entrypoint side is the load-bearing half so it ships first. * HIGH-5 — `first_boot_install` no longer emits the "log in as CONSOLE" nudge (impossible to follow — the CONSOLE row carries an empty password and `NormalAuthHandler` rejects empty auth). Instead: if `SBPP_AUTO_INSTALL=1` and any of `INITIAL_ADMIN_{NAME,STEAM,EMAIL,PASSWORD}` are empty, die loud with a clear remediation message. If `SBPP_AUTO_INSTALL=0`, skip the schema bootstrap AND `strip_install_dirs` so the wizard surface stays reachable at `/install/`. * MED-1 — `resolve_file_secret` rejects empty files (`[ -z … ] die "${file_path} is empty"`) — a zero-byte secret was previously treated as a successfully-resolved empty value. * MED-2 — `render_config` runs `php -l "${SBPP_CONFIG_PATH}"` on the existing config before short-circuiting, so a corrupted / partially-truncated config.php from a crashed prior run surfaces in the boot log instead of as a 500 on every page load. * MED-3 — Install sentinel switched from "does `{prefix}_admins` table exist" to "does `{prefix}_settings.config.version` row exist". The new check is the second-to-last `INSERT` in `data.sql`, so its presence proves `data.sql` ran to completion. Pre-fix, a crash mid-`data.sql` would leave the admins table populated and skip the install pass on next boot, booting against a half-seeded `:prefix_settings`. Implemented via the new `sb-db.php has-version-row <prefix>` subcommand. * MED-5 — `render_config` rejects an explicit `SBPP_CONFIG_PATH` whose parent directory doesn't exist (die — the operator intended a mount that isn't there) and warns if the parent is on the same `st_dev` as `/` (likely the writable layer; config won't persist across `docker run` recreations). * LOW-1 — `set -eu` upgraded to `set -euo pipefail` so pipeline failures (e.g. `sed | run_sql` for the schema apply) propagate. Paired with a `shellcheck disable=SC3040` annotation explaining why dash 0.5.11+ / busybox ash 1.31+ both support pipefail despite POSIX-pre-2024 not requiring it. * NIT-2 — `resolve_file_secret` strips trailing `\r\n` so the ergonomic `echo "secret" > /run/secrets/db_pass` shape (which appends a newline) Just Works. New file `docker/php/sb-db.php` is the load-bearing replacement for the `mysqladmin` / `mysql` CLI tools the entrypoint previously shelled out to. Three subcommands: * `ping` — open a PDO connection (server-level, no `dbname=`) and exit 0/1. Wraps `mysqladmin ping`. * `has-version-row PREFIX` — return 0 if the `:prefix_settings.config.version` row exists, 1 otherwise. PDOException 42S02 (table missing) maps to 1 so a truly fresh DB still triggers the install pass. * `exec` — read SQL from STDIN, split on top-level `;` while respecting `'` / `"` / `\`` quoted strings, `--` / `#` / `/* */` comments, and backslash escapes, then run each statement via `PDO::exec`. Fails loud (exit 1, stderr diagnostic) on the first error. Replaces `mysql < schema.sql`. The conservative parser is the load-bearing piece — pre-fix the wizard's `page.5.php` uses `explode(';', $sql)` against `data.sql` which happens to work today because no value-side `;` exists in the shipped seeds, but the production entrypoint runs against operator-mounted schema deltas where that's not safe to assume. Smoke-tested every subcommand against the dev MariaDB (`docker exec sbpp-1381-web php /tmp/sb-db.php ...`): ping=0 with right creds, ping=1 with wrong host, has-version-row=0 on the seeded `sb_` prefix, has-version-row=1 on a non-existent prefix, exec=0 on a heredoc carrying `;` inside string literals and each of the three comment shapes. shellcheck clean (the two `SC2016` disables on `php -r 'echo urldecode(...)'` lines are intentional — single quotes there are PHP source, not shell expansion).
…HIGH-3) Pair with the previous entrypoint commit that moved `mysqladmin ping` + `mysql < schema.sql` to the bundled `docker/php/sb-db.php` PDO helper. The runtime apt line shrinks by `default-mysql-client` (~22 MB) — closing the spec violation the #1381 reviewer flagged ("the spec explicitly says NO `default-mysql-client`; worker shipped it anyway"). Also stage `sb-db.php` to `/usr/local/lib/sbpp/sb-db.php` so it stays reachable after the entrypoint's step-7 `rm -rf install/+updater/` pass — keeping it under `/var/www/html/web/` would have made it disappear mid-boot on every restart. hadolint clean.
…es (#1381 CRIT-3 + MED-4) * CRIT-3 — `/health.php` previously echoed `FAIL: $e->getMessage()` to any unauth caller. PDO carries SQLSTATE plus DB host plus internal IP plus username plus auth mechanism in its exception text (`SQLSTATE[HY000] [1045] Access denied for user 'sourcebans'@'10.0.5.13'`) — a reconnaissance gift the probe handed out on every misconfigured boot. Body is now just `FAIL\n` with the full detail going to `error_log` for the operator. Status code unchanged at 503; the contract for Docker HEALTHCHECK / k8s liveness / app-platform routers is preserved. * MED-4 — `/health.php` now `define`s `SBPP_SKIP_TELEMETRY` BEFORE requiring `init.php`. The shutdown function `init.php` registers (`Telemetry::tickIfDue`) early-returns when the constant is set, so the orchestrator's ~30s probe doesn't cost a `:prefix_settings.last_ping` `UPDATE` per call (~2880/day of pure noise). The rate-limit guard inside the tick would already prevent a real cURL POST per probe, but the slot-reservation `UPDATE` is the actual wasted write. Constraint: `web/health.php` is now 49 lines (was 78) and still well under the <50-line review-ability cap. Regression guard: new `TelemetryOptOutTest::testTickIfDueShortCircuitsWhenSbppSkipTelemetryDefined` verifies the constant blocks both the slot reservation AND the instance-ID mint, in a `#[RunInSeparateProcess]` so the rest of the suite doesn't inherit the opt-out. All 4 tests in the file pass (3 pre-existing + the new one).
…T-4) Pre-fix `\Sbpp\Auth\Host::isSecure()` returned `true` for any request that arrived with `X-Forwarded-Proto: https`, regardless of where the request actually came from. An attacker hitting the panel directly on HTTP could spoof the header and trick the panel into: * setting `Secure` on every cookie it issued (which the browser would then refuse to send back over the attacker's plain-HTTP channel — silent session breakage); * emitting `https://…` redirects from `Host::protocol()`; * lying to every `Host::complete()` caller about the actual request scheme. The fix is a two-arm resolution order: 1. `$_SERVER['HTTPS'] === 'on'` — authoritative. On the production Docker image Apache's `mod_remoteip` plus the paired `SetEnvIfExpr` mirror a trusted upstream's `X-Forwarded-Proto: https` into `HTTPS=on`, keeping the trust decision in Apache (the right layer). 2. `$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'` ONLY if `$_SERVER['REMOTE_ADDR']` matches the operator-defined `SBPP_TRUSTED_PROXIES` constant. This is the fallback for non-Docker deployments where the operator never wired up `mod_remoteip` and pins the trust list in `config.php` instead. `SBPP_TRUSTED_PROXIES` lives in `init.php`: a `config.php` `define()` wins, otherwise the `SBPP_TRUSTED_PROXIES` env var (which the prod entrypoint already exports), otherwise empty (the secure default — XFP ignored entirely). The list is whitespace-separated and accepts IPv4 + IPv6 literals AND CIDR ranges; the new `Host::isTrustedProxy()` / `ipMatchesRange()` helpers parse it via `inet_pton` so both address families share one code path. Family mismatch (IPv4 IP against IPv6 CIDR or vice versa) is rejected explicitly so an attacker can't construct an IPv4 binary that prefix-matches an IPv6 CIDR. The dev container's behaviour is preserved: the dev compose stack doesn't define `SBPP_TRUSTED_PROXIES`, so the env var is absent, the trust list is empty, the XFP fallback never fires, and `isSecure()` returns `false` on every plain-HTTP request (unchanged from before). Regression guard: 13 new test cases in `web/tests/unit/Auth/HostIsSecureTest.php` covering every arm — the four primary scenarios called out by the reviewer (`HTTPS=on` → true; no headers → false; XFP from untrusted IP → false; XFP from trusted IP → true) plus IPv6 CIDR + literal + family-mismatch + whitespace-only-trust-list + empty-trust-list cases. Each case runs in `#[RunInSeparateProcess]` so the process-global `SBPP_TRUSTED_PROXIES` constant doesn't leak between scenarios. PHPStan + PHPUnit (the 13 new + the existing suite) clean.
…HIGH-1 + HIGH-2)
* HIGH-1 — `web/index.php` no longer carries the redundant
`include_once('config.php')` from line 8. `init.php` already
loaded config via `sbpp_resolve_config_path()` (the helper that
honours `SBPP_CONFIG_PATH` for Docker-secret mounts); the
second literal include either fails outright (no `config.php`
in `web/`) or shadows the secret-mounted values with whatever
stale on-disk `web/config.php` happened to exist from a prior
install.
Removes one entry from `web/phpstan-baseline.neon`
(`includeOnce.fileNotFound` against `index.php` line 8) — the
baseline entry was load-bearing because PHPStan couldn't see
through the `getenv()` fork in `sbpp_resolve_config_path` and
flagged the literal `config.php` as "may not exist". With the
literal gone, the rule fires zero times and the baseline shed
one entry.
* HIGH-2 — `web/install/pages/page.5.php`'s
`sbpp_install_render_config()` call site now resolves the
config-write path via `sbpp_resolve_config_path(PANEL_ROOT .
'config.php')` instead of hardcoding `PANEL_ROOT . 'config.php'`.
An operator running the wizard manually (typical
`SBPP_AUTO_INSTALL=0` shape: prod Docker image with a
Docker-secret-mounted config) gets the wizard's "config.php
written successfully" green light AND the file actually lands
at the path the panel runtime will read at boot. Pre-fix it
landed in the writable layer that gets blown away on every
`docker run --rm` recreation.
Wired up by sourcing `web/init-recovery.php` from
`web/install/bootstrap.php` — the helper has zero `Sbpp\…`
dependencies so it's safe to pull into the wizard's
no-Composer-chrome contract.
PHPStan + InstallGuardTest (8 tests, 28 assertions) clean. The
existing `testWizardGuardHonorsConfigPathEnvVar` test already
pins the matching guard on `web/install/already-installed.php`,
so the surfaces stay symmetric.
…EXPERIMENTAL (#1381 MED-6 + HIGH-4 + NIT-1) * MED-6 — `push:` previously specified `branches`, `tags`, AND `paths` at the same level, which GitHub Actions AND-combines. A release-tag push pointing at a commit that didn't itself touch the prod-image surface (the common case — release tags are typically attached to commits that already shipped through `main` and the image was rebuilt there) would silently skip the workflow and `:latest` would lag behind `:main` indefinitely. The fix drops the `paths` filter from `push:` so every push to `main` AND every release-tag push triggers a full multi-arch build. Action minutes for tag pushes are unconditional (which is the right contract for a release surface), and main pushes are rare enough (PRs aggregate via squash merges) that the per-merge cost is acceptable. The PR trigger keeps its `paths` filter so the per-PR action-minute optimisation that originally motivated the whole filter is preserved on the trigger that actually runs on every PR commit. * HIGH-4 — the `pull_request:` `paths:` filter was missing the panel-side files the prod image bakes in: - `web/health.php` — the file Docker HEALTHCHECK pings. - `web/init.php` / `web/init-recovery.php` — the `SBPP_CONFIG_PATH` plumbing the entrypoint relies on. - `web/install/includes/sql/**` — the schema + seed data the entrypoint loads on first-boot install. - `web/includes/Auth/Host.php` — the new `SBPP_TRUSTED_PROXIES` gate (#1381 CRIT-4). - `web/includes/Telemetry/Telemetry.php` — the `SBPP_SKIP_TELEMETRY` hook health.php depends on (MED-4). - `web/composer.json` / `web/composer.lock` — vendor churn. - `docker/php/sb-db.php` — the new PDO helper (HIGH-3) the entrypoint shells out to. Adding these closes the gap where a PR could ship an entrypoint-incompatible change to a panel-side file (e.g. a refactor of `Telemetry::tickIfDue` that drops the `SBPP_SKIP_TELEMETRY` early-return) and the prod-image CI would happily report green because it never ran. * NIT-1 — `COSIGN_EXPERIMENTAL=1` is the legacy gate for cosign 1.x keyless signing. Cosign 2.0+ promoted keyless to the default, and 2.4.x (the version pinned by `cosign-installer@v3` above) silently ignores the env var. Carrying it implied "there's still an experimental flag in play here" — drop it; replace with an inline comment so a future reader doesn't add it back. actionlint clean.
… LOW-3) `PORT` and `SBPP_HOST_PORT` look interchangeable at a glance but do opposite things: * `PORT` rewrites Apache's `Listen` directive INSIDE the container. Used by app-platform deploys (Render / Fly / Heroku) that inject the env var and expect the app to bind to it. * `SBPP_HOST_PORT` is the HOST-side port `docker-compose.prod.yml` maps to the container's hardcoded port 80. Setting `PORT=8000` on a compose deploy 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 — exactly the LOW-3 trap the #1381 reviewer flagged. * `.env.example.prod` grew a commented-out `PORT=` entry with an inline warning so an operator who reads top-to-bottom sees the trap before they reach for the knob. * `quickstart-docker.mdx` grew a `:::caution` admonition next to the `PORT` mention in the app-platform "one-click deploy" section, explaining the asymmetry between the two surfaces. No-op behavioural change; docs / .env clarity only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1381.
Summary
Ships the five deliverables from #1381 — a production-ready Docker image, a self-hoster compose file, GHCR publishing + cosign signing in CI, and matching docs — together with the cross-cutting one-click-deploy friendliness constraints (PORT, DATABASE_URL, *_FILE secrets, SBPP_CONFIG_PATH, mod_remoteip trust list, unauthenticated DB-aware healthcheck).
The image is a multi-stage build distinct from the dev
docker/Dockerfile. The contracts diverge enough (noSBPP_DEV_KEEP_INSTALL, noauto_prepend_file, no nodejs/npm/git/composer/mysql-client in the runtime stage, OPcache tuned for prod, non-rootwww-data) that a single Dockerfile would either bloat dev or risk prod silently inheriting a dev-only knob. The prod entrypoint owns the install + migrate state machine the dev stack ducks viaSBPP_DEV_KEEP_INSTALL+ the seeded DB — it drives a headless install fromINITIAL_ADMIN_*env vars, runs pending updater migrations, and removesinstall/+updater/from the writable layer before handing off to Apache so the runtime install-guard from #1335 C1 stays armed.The whole change went through an adversarial-review pass before merge: a reviewer surfaced 6 CRITICAL + 5 HIGH + 6 MEDIUM + 4 LOW + 4 NIT findings (URL-decoder broken under dash,
INITIAL_ADMIN_PASSWORDleaking into long-running Apache env, healthcheck leaking PDO error text to unauth callers,Host::isSecure()trustingX-Forwarded-Protofrom anyone, migration failures silently swallowed, unvalidatedDB_PREFIX/DB_NAMEin SQL/sed,web/index.phpignoringSBPP_CONFIG_PATH, wizard hardcoding the write path,default-mysql-clientshipping against spec, CI path-filter missing files that affect the image, the "log in as CONSOLE row only" nudge being impossible, …). Every CRITICAL + HIGH is fixed; the higher-impact MEDIUMs (empty-file secret resolver,config.phpsyntax validation,_settings.versionsentinel against the_admins-exists race, telemetry-tick skip on the healthcheck,SBPP_CONFIG_PATHto an unmounted volume) plus all cheap LOW/NITs are fixed too.Notable design choices:
SBPP_AUTO_INSTALL=1) — app platforms preview-deploy containers before they're routable, so a wizard-driven flow would never get an interactive operator.SBPP_AUTO_INSTALL=0is the escape hatch for operators who want the wizard (in that path,install/is left in place).SBPP_CONFIG_PATHdecouplesconfig.phpfrom the image's read-only layers; some app platforms run with read-only roots + a writable/datamount. Threaded throughinit.php,index.php,init-recovery.php, the install wizard's render step, andalready-installed.php.Host::isSecure()now gatesHTTP_X_FORWARDED_PROTOon$_SERVER['REMOTE_ADDR']being inSBPP_TRUSTED_PROXIES(CIDR + literal IPv4/IPv6 supported). Pre-fix any client could spoofX-Forwarded-Proto: httpsand silently flip the panel'sSecurecookie / redirect / link-builder logic. New regression guard atweb/tests/unit/Auth/HostIsSecureTest.php(13 cases).docker/php/sb-db.phpso the runtime image doesn't needdefault-mysql-client; saves ~22MB and removes a shell-injection surface in the schema-loading path.web/health.phpis unauthenticated, DB-aware, and surfaces onlyunhealthy\non failure — the fullPDOExceptionchain stays inerror_log()for operator debug but doesn't leak DB host / username / auth-mode to public callers.cosign verifyagainst the GitHub Actions identity.The dev stack is untouched.
./sbpp.sh upkeeps the bind-mount +SBPP_DEV_KEEP_INSTALL+ dev OPcache model that's load-bearing for PR review velocity.Test plan
Quality gates that ran clean locally:
php -lon the 13 touched PHP files (web/health.php,web/init.php,web/init-recovery.php,web/index.php,web/install/already-installed.php,web/install/bootstrap.php,web/install/pages/page.5.php,web/includes/Auth/Host.php,web/includes/Telemetry/Telemetry.php, the three new tests,docker/php/sb-db.php)../sbpp.sh phpstan—[OK] No errors(238 files)../sbpp.sh test—OK (611 tests, 2529 assertions)../sbpp.sh ts-check— clean../sbpp.sh composer api-contract— no diff (no handler changes).shellcheck docker/php/prod-entrypoint.sh— clean (set -euo pipefailwith the SC3040 disable carrying its rationale).hadolint docker/Dockerfile.prod— clean.actionlint .github/workflows/docker-image.yml— clean.Docker-specific smoke testing (recommended to run before / shortly after merge — can't be exercised inside the worktree's sibling-container constraint):
docker buildx build --platform linux/amd64,linux/arm64 -f docker/Dockerfile.prod -t sbpp:test .. Verifydocker run --rm sbpp:test php -mlists exactlypdo_mysql,gmp,intl,zip,mbstring(plus core) and nogit/composer/xdebug/mysql.cp .env.example.prod .env, fillSB_SECRET_KEY/DB_*/INITIAL_ADMIN_*,docker compose -f docker-compose.prod.yml up -d. Verify the entrypoint logs walk the state machine,/health.phpreturns 200 with bodyOK\n, panel login asINITIAL_ADMIN_NAMEworks,install/andupdater/are absent in the running container (docker exec sbpp-web ls web/install web/updater).docker compose restart web— verify entrypoint short-circuits (no schema re-install, no updater re-run), panel stays logged in (SB_SECRET_KEYpersisted throughconfig.php).DATABASE_URLparse path with URL-encoded password (mysql://user:p%40ss%23word@db:3306/sourcebans?charset=utf8mb4) — verify PDO connect succeeds (the URL-decoder fix for dash-vs-bash).*_FILEsecret resolution: mount/run/secrets/db_password, setDB_PASS_FILE=/run/secrets/db_password, verify connection. Then truncate the secret file to 0 bytes — verify entrypoint dies withis emptyrather than connecting passwordless.PORTenv injection:docker run -e PORT=8080 -p 8080:8080 sbpp:test— verify Apache listens on 8080 (Render/Fly contract).SBPP_TRUSTED_PROXIES=172.16.0.0/12, hit panel via the bundled Caddy stub, verify$_SERVER['REMOTE_ADDR']is the real client IP inLog::addentries. Then hit/directly bypassing the proxy withX-Forwarded-Proto: https— verifyHost::isSecure()reportsfalse(the CRIT-4 fix).dbservice, hit/health.php, verify response body is justunhealthy\n(noSQLSTATE/ no internal IP) and HTTP status is 503.web/updater/data/9999.phpthat returnsfalse, register it instore.json, restart container — verify entrypoint dies non-zero with the failure marker from the message stack (the CRIT-5 fix).docker-image.ymlworkflow builds both arches, pushes to GHCR, and produces a cosign signature.