From 6fbfc3c643901d7085ce53b1d97554ff7919d9eb Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 20:28:58 -0400 Subject: [PATCH 01/13] feat(panel): honour SBPP_CONFIG_PATH for Docker-secret config mounts (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/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. --- web/init-recovery.php | 38 ++++++++++ web/init.php | 12 ++- web/install/already-installed.php | 19 ++++- web/tests/integration/InstallGuardTest.php | 85 ++++++++++++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) 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..fdb63289e 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,7 @@ header('Location: install/'); exit; } -require_once(ROOT.'/config.php'); +require_once($sbppConfigPath); // 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/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 From e9d851b0cfaf2a9c567f04275a21dd0e79a04466 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 20:29:16 -0400 Subject: [PATCH 02/13] feat(docker): production image + entrypoint state machine (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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. --- docker/Dockerfile.prod | 233 ++++++++++++ docker/apache/sbpp-prod.conf | 84 +++++ docker/php/prod-entrypoint.sh | 647 ++++++++++++++++++++++++++++++++++ docker/php/prod-php.ini | 100 ++++++ web/health.php | 78 ++++ 5 files changed, 1142 insertions(+) create mode 100644 docker/Dockerfile.prod create mode 100644 docker/apache/sbpp-prod.conf create mode 100755 docker/php/prod-entrypoint.sh create mode 100644 docker/php/prod-php.ini create mode 100644 web/health.php diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod new file mode 100644 index 000000000..c22bdca3f --- /dev/null +++ b/docker/Dockerfile.prod @@ -0,0 +1,233 @@ +# 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 + +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. `gmp` is required by +# `Sbpp\SteamID` (the SteamID2/3/64 math layer) post-#1385's drop of the +# BCMath fallback. `intl`, `zip`, `mbstring` mirror dev. `pdo_mysql` is +# the panel's only DB driver. `gettext-base` is brought in solely for +# `envsubst`, which the entrypoint uses for safe variable substitution +# in the rendered config.php. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + gettext-base \ + libicu-dev \ + libzip-dev \ + libonig-dev \ + libgmp-dev \ + default-mysql-client \ + tzdata \ + && docker-php-ext-install -j"$(nproc)" \ + pdo_mysql \ + intl \ + zip \ + mbstring \ + gmp \ + && a2enmod rewrite headers remoteip setenvif \ + && rm -rf /var/lib/apt/lists/* + +# `default-mysql-client` is in the runtime image ONLY for the entrypoint's +# `mysqladmin ping` wait-for-DB loop. The 22 MB cost is worth not +# re-implementing the wait loop in PHP (which would need a temporary +# script staged before init.php is reachable). The client is unused at +# request time and never exposed to the panel. + +# 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). +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 + +# 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. +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/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/php/prod-entrypoint.sh b/docker/php/prod-entrypoint.sh new file mode 100755 index 000000000..ee061548d --- /dev/null +++ b/docker/php/prod-entrypoint.sh @@ -0,0 +1,647 @@ +#!/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. + +set -eu + +WEB_ROOT="/var/www/html/web" +LOG_PREFIX="[prod-entrypoint]" +SBPP_AUTO_INSTALL="${SBPP_AUTO_INSTALL:-1}" + +log() { printf '%s %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 + eval "$name=\$(cat \"\$file_path\")" + export "$name" + 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`). Defensive — `printf '%b'` interprets the printf-style + # escape sequences and the substitution converts URL escapes + # to those. + DB_USER="$(printf '%b' "$(echo "$_DB_USER" | sed 's/%/\\x/g')")" + DB_PASS="$(printf '%b' "$(echo "$_DB_PASS" | sed 's/%/\\x/g')")" + 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 +} + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- +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 mysqladmin ping \ + -h"${DB_HOST}" -P"${DB_PORT}" \ + -u"${DB_USER}" -p"${DB_PASS}" \ + --skip-ssl --silent 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. Honours the connection +# settings configured above; reads its body from stdin. +run_sql() { + mysql \ + -h"${DB_HOST}" -P"${DB_PORT}" \ + -u"${DB_USER}" -p"${DB_PASS}" \ + --skip-ssl --silent --skip-column-names \ + --default-character-set="${DB_CHARSET}" \ + "${DB_NAME}" +} + +# --------------------------------------------------------------------------- +# Step 4: render config.php +# --------------------------------------------------------------------------- +render_config() { + if [ -s "${SBPP_CONFIG_PATH}" ]; then + 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")" + [ -d "$cfg_dir" ] || mkdir -p "$cfg_dir" + + sb_esc() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/\\\\'/g" + } + + cat > "${SBPP_CONFIG_PATH}" </dev/null || true)" + if [ -n "$exists" ]; then + log "step 5: ${table} already exists — skipping first-boot install" + 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 + + # Pipe schema with substitutions (mirror docker/db-init/00-render-schema.sh + # exactly — same prefix, same charset, same render order). + 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 (or skip with a clear next-step nudge). + if [ -z "$INITIAL_ADMIN_NAME" ] \ + || [ -z "$INITIAL_ADMIN_STEAM" ] \ + || [ -z "$INITIAL_ADMIN_EMAIL" ] \ + || [ -z "$INITIAL_ADMIN_PASSWORD" ]; then + log "step 5: INITIAL_ADMIN_{NAME,STEAM,EMAIL,PASSWORD} not all set — admin row not seeded" + log "step 5: log in as the CONSOLE row only; seed an admin manually via the panel before going live" + return + fi + + 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/')" + + 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. + # + # The mysql client connects via the panel's runtime user/pass which + # was already verified by wait_for_db. Quoting: we pass values via + # `printf %q` shell-escape into a single-string SQL stmt; since the + # password hash and the admin name come from operator env vars, + # they're trusted by definition (the operator set them). The + # authid is regex-validated above. The email is validated by the + # panel UI later but here we just splat it in. + 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. + 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'; + +$updater = new \Updater($pdo); +foreach ($updater->getMessageStack() as $line) { + fwrite(STDERR, "[prod-entrypoint][step 6] " . strip_tags((string) $line) . "\n"); +} +PHP + + rc=$? + if [ "$rc" -ne 0 ]; then + die "updater run failed (exit $rc) — refusing to start panel" + 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() { + 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 + 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 + + log "boot complete — handing off to: $*" + exec "$@" +} + +main "$@" diff --git a/docker/php/prod-php.ini b/docker/php/prod-php.ini new file mode 100644 index 000000000..144d5c51d --- /dev/null +++ b/docker/php/prod-php.ini @@ -0,0 +1,100 @@ +; SourceBans++ production PHP tuning (#1381). +; +; Loaded as /usr/local/etc/php/conf.d/zz-sbpp-prod.ini in the runtime +; image; the `zz-` prefix wins specificity over any vendor default +; conf shipped by php:8.5-apache. See docker/Dockerfile.prod. +; +; Conservative-by-default: anything that touches per-request behaviour +; (display_errors, OPcache file checks, file-upload size) is set explicitly +; rather than relying on the upstream php.ini. +; +; The dev image's settings (display_errors=On, opcache.revalidate_freq=0, +; auto_prepend_file=dev-prepend.php) are deliberately absent. The dev +; image opts INTO chatty errors + per-request file-checks + a constant +; that bypasses init.php's install-presence guard. None of those make +; sense on a production panel. + +; --------------------------------------------------------------------------- +; Errors +; --------------------------------------------------------------------------- + +; 12-factor: emit errors to the orchestrator's stderr, never to the +; HTML response body. `display_errors=On` would leak file paths, +; SQL fragments, and stack traces to anyone who tickled a panel +; error — a small but real information-disclosure surface. +display_errors = Off +display_startup_errors = Off +log_errors = On +error_log = /dev/stderr + +; The panel's own `sbError` handler (init.php) routes E_USER_* +; notices into the audit log. Everything else goes to stderr. +error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT + +; Don't tell the world this is PHP / which version. +expose_php = Off + +; --------------------------------------------------------------------------- +; Limits +; --------------------------------------------------------------------------- + +memory_limit = 256M +max_execution_time = 60 +post_max_size = 32M +upload_max_filesize = 32M + +; Sessions live on disk by default (Smarty's compile dir + cache dir +; are on the `cache` volume, which carries `cache/sessions/`). Cookie +; lifetime, samesite, and secure flag are managed by the panel (Auth +; module); the ini-level defaults below are fallbacks for any code +; that calls session_start() outside Auth. +session.cookie_httponly = 1 +session.cookie_samesite = "Lax" +session.use_strict_mode = 1 +session.gc_maxlifetime = 86400 + +; --------------------------------------------------------------------------- +; OPcache (production knobs) +; --------------------------------------------------------------------------- +; +; The dev image runs with revalidate_freq=0 so file edits are picked +; up on the next request — perfect for development, terrible for +; production (every request stat()s every loaded file, killing perf +; under any non-trivial load). +; +; Production: validate_timestamps=0 means OPcache NEVER re-checks +; files on disk. The image is immutable; a `docker compose pull && +; up -d` cycle replaces the container, which is the correct +; invalidation event. If a self-hoster needs to in-place edit a +; file (third-party theme tweak, etc.) and pick it up without a +; container restart, they can `docker compose exec web php -r +; 'opcache_reset();'` — but that's the rare exception, not the +; happy path. + +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.memory_consumption = 128 +opcache.interned_strings_buffer = 16 +; Web includes ~3.5k vendor files + ~1.5k panel files; round up. +opcache.max_accelerated_files = 6000 +opcache.validate_timestamps = 0 +opcache.revalidate_freq = 0 +opcache.fast_shutdown = 1 +opcache.save_comments = 1 + +; JIT — disabled by default on production because the gain is small for +; a request-response panel like SourceBans++ and the surface for jit +; bugs is non-zero. Self-hosters can flip this on by overriding the +; conf via a bind-mounted `*.ini`; keeping the default conservative. +opcache.jit_buffer_size = 0 + +; --------------------------------------------------------------------------- +; Date +; --------------------------------------------------------------------------- + +; UTC by default — orchestrator-timestamped logs land in the same +; timezone the audit log writes ints (every panel `created` column is +; a unix epoch, timezone-agnostic). Self-hosters override via the +; `TZ` env var (read by libc; PHP follows automatically) when they +; want local-time stderr. +date.timezone = "UTC" diff --git a/web/health.php b/web/health.php new file mode 100644 index 000000000..b309eecf3 --- /dev/null +++ b/web/health.php @@ -0,0 +1,78 @@ +query('SELECT 1'); + $row = $db->single(); + + if (is_array($row) && (int) reset($row) === 1) { + http_response_code(200); + echo "OK\n"; + exit; + } + + http_response_code(503); + echo "FAIL: unexpected SELECT 1 result\n"; + error_log('[health.php] SELECT 1 returned unexpected shape'); + exit; +} catch (\Throwable $e) { + http_response_code(503); + echo "FAIL: " . $e->getMessage() . "\n"; + // Echo to stderr too — the orchestrator captures it via the + // container's log stream. + error_log('[health.php] DB probe failed: ' . $e->getMessage()); + exit; +} From 43f857df006f43c1037c30bea1b4bcb35acdd370 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 20:29:30 -0400 Subject: [PATCH 03/13] feat(docker): production compose + .env example + Caddy stub (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example.prod | 191 ++++++++++++++++++++++++++++++ docker-compose.prod.yml | 205 +++++++++++++++++++++++++++++++++ docker/caddy/Caddyfile.example | 44 +++++++ 3 files changed, 440 insertions(+) create mode 100644 .env.example.prod create mode 100644 docker-compose.prod.yml create mode 100644 docker/caddy/Caddyfile.example diff --git a/.env.example.prod b/.env.example.prod new file mode 100644 index 000000000..95a907a70 --- /dev/null +++ b/.env.example.prod @@ -0,0 +1,191 @@ +# 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 + +# 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/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/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" +} From 70d03ff314729cd6968763db6f97001823d24e0f Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 20:29:43 -0400 Subject: [PATCH 04/13] ci(docker): build + push multi-arch image to GHCR with cosign (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:`, and signs each tag with Sigstore cosign in keyless (OIDC) mode. Triggers + tag mapping: - push to main → :main, :sha- - 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 (`@`) 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. --- .github/workflows/docker-image.yml | 196 +++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 .github/workflows/docker-image.yml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000..7d64d66d4 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,196 @@ +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: +# - docker/Dockerfile.prod, docker/php/prod-*, docker/apache/sbpp-prod.conf +# - web/health.php — the file the HEALTHCHECK pings +# - web/init.php / init-recovery.php — the SBPP_CONFIG_PATH plumbing +# that the entrypoint relies on +# - .github/workflows/docker-image.yml — touch-the-workflow trigger +# - composer.json/lock under web/ — vendor dependency churn +# +# We deliberately don't trigger on every web/** edit (the panel 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. + +on: + push: + branches: + - main + paths: + - 'docker/Dockerfile.prod' + - 'docker/php/prod-*' + - 'docker/apache/sbpp-prod.conf' + - 'web/composer.json' + - 'web/composer.lock' + - 'web/health.php' + - 'web/init.php' + - 'web/init-recovery.php' + - '.github/workflows/docker-image.yml' + tags: + - '*.*.*' + pull_request: + paths: + - 'docker/Dockerfile.prod' + - 'docker/php/prod-*' + - 'docker/apache/sbpp-prod.conf' + - '.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: + COSIGN_EXPERIMENTAL: '1' + 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" From 0af64aa766cbcd3902ff06b7685334f9ebd5efe2 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 20:34:09 -0400 Subject: [PATCH 05/13] docs(docker): production quickstart + AGENTS sync (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- AGENTS.md | 47 +- docker/README.md | 11 + docs/astro.config.mjs | 1 + .../content/docs/getting-started/overview.mdx | 59 ++- .../docs/getting-started/prerequisites.mdx | 65 ++- .../getting-started/quickstart-docker.mdx | 419 ++++++++++++++++++ 6 files changed, 590 insertions(+), 12 deletions(-) create mode 100644 docs/src/content/docs/getting-started/quickstart-docker.mdx 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/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/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..93219e4ad --- /dev/null +++ b/docs/src/content/docs/getting-started/quickstart-docker.mdx @@ -0,0 +1,419 @@ +--- +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. +- **`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? + + + + + + From 9f4c73faa9479ef4ac961150d938f1d2d2f1e215 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 20:44:44 -0400 Subject: [PATCH 06/13] chore(docker): silence shellcheck SC2163/SC2016 + hadolint DL3008/SC2016 in prod image (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/Dockerfile.prod | 32 ++++++++++++++++++++++++++------ docker/php/prod-entrypoint.sh | 16 +++++++++++++++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index c22bdca3f..26ac3fe49 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -45,6 +45,12 @@ 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 \ @@ -87,12 +93,16 @@ ENV DEBIAN_FRONTEND=noninteractive \ # 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. `gmp` is required by -# `Sbpp\SteamID` (the SteamID2/3/64 math layer) post-#1385's drop of the -# BCMath fallback. `intl`, `zip`, `mbstring` mirror dev. `pdo_mysql` is -# the panel's only DB driver. `gettext-base` is brought in solely for -# `envsubst`, which the entrypoint uses for safe variable substitution -# in the rendered config.php. +# 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. +# (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 \ @@ -130,6 +140,14 @@ 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 \ @@ -211,6 +229,8 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \ # `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/* diff --git a/docker/php/prod-entrypoint.sh b/docker/php/prod-entrypoint.sh index ee061548d..f6b194320 100755 --- a/docker/php/prod-entrypoint.sh +++ b/docker/php/prod-entrypoint.sh @@ -65,7 +65,13 @@ resolve_file_secret() { eval "file_path=\${$file_var:-}" if [ -n "$file_path" ] && [ -f "$file_path" ]; then eval "$name=\$(cat \"\$file_path\")" - export "$name" + # `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 } @@ -464,6 +470,14 @@ seed_initial_admin() { # 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" From 40d0f7fea9212fc14af030ce1a13b4e513604c7b Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 21:17:14 -0400 Subject: [PATCH 07/13] fix(docker): harden prod entrypoint per #1381 review (CRIT-1/2/5/6, HIGH-3/5, MED-1/2/3/5, LOW-1, NIT-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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). --- docker/php/prod-entrypoint.sh | 384 +++++++++++++++++++++++++++++----- docker/php/sb-db.php | 288 +++++++++++++++++++++++++ 2 files changed, 618 insertions(+), 54 deletions(-) create mode 100644 docker/php/sb-db.php diff --git a/docker/php/prod-entrypoint.sh b/docker/php/prod-entrypoint.sh index f6b194320..6e2f1e967 100755 --- a/docker/php/prod-entrypoint.sh +++ b/docker/php/prod-entrypoint.sh @@ -37,14 +37,34 @@ # session. We need install/ gone before Apache binds — the # post-#1335 panel-runtime guard refuses to boot otherwise. -set -eu +# `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}" -log() { printf '%s %s\n' "$LOG_PREFIX" "$*" >&2; } -die() { printf '%s ERROR: %s\n' "$LOG_PREFIX" "$*" >&2; exit 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) @@ -64,7 +84,24 @@ resolve_file_secret() { file_var="${name}_FILE" eval "file_path=\${$file_var:-}" if [ -n "$file_path" ] && [ -f "$file_path" ]; then - eval "$name=\$(cat \"\$file_path\")" + # 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. @@ -147,11 +184,26 @@ parse_database_url() { ;; esac # URL-decode percent-escapes (a password with `@` arrives as - # `%40`). Defensive — `printf '%b'` interprets the printf-style - # escape sequences and the substitution converts URL escapes - # to those. - DB_USER="$(printf '%b' "$(echo "$_DB_USER" | sed 's/%/\\x/g')")" - DB_PASS="$(printf '%b' "$(echo "$_DB_PASS" | sed 's/%/\\x/g')")" + # `%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 @@ -251,6 +303,66 @@ apply_defaults() { 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) # --------------------------------------------------------------------------- @@ -308,14 +420,20 @@ CONF # --------------------------------------------------------------------------- # 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 mysqladmin ping \ - -h"${DB_HOST}" -P"${DB_PORT}" \ - -u"${DB_USER}" -p"${DB_PASS}" \ - --skip-ssl --silent 2>/dev/null; then + if php "${SBPP_LIB_DIR}/sb-db.php" ping 2>/dev/null; then log "step 3: DB is up" return fi @@ -325,15 +443,10 @@ wait_for_db() { die "DB at ${DB_HOST}:${DB_PORT} never came up — giving up after 60s" } -# Run a SQL command via the panel's DB user. Honours the connection -# settings configured above; reads its body from stdin. +# Run a SQL command via the panel's DB user. Reads its body from +# stdin. Replaces the legacy `mysql < … `-shaped helper. run_sql() { - mysql \ - -h"${DB_HOST}" -P"${DB_PORT}" \ - -u"${DB_USER}" -p"${DB_PASS}" \ - --skip-ssl --silent --skip-column-names \ - --default-character-set="${DB_CHARSET}" \ - "${DB_NAME}" + php "${SBPP_LIB_DIR}/sb-db.php" exec } # --------------------------------------------------------------------------- @@ -341,6 +454,17 @@ run_sql() { # --------------------------------------------------------------------------- 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 @@ -356,7 +480,39 @@ render_config() { # 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")" - [ -d "$cfg_dir" ] || mkdir -p "$cfg_dir" + + # 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" @@ -411,24 +567,40 @@ PHP # Step 5: first-boot install (schema + seed admin) # --------------------------------------------------------------------------- # -# Fresh DBs (no `:prefix_admins` table) get the schema + seed pass -# below. Existing DBs (table present) skip — even if INITIAL_ADMIN_* -# env vars are set, we never re-create the admin (would clobber the -# existing one's password / Steam ID and silently lock the operator -# out of their own panel). +# Fresh DBs get the schema + seed pass below. Existing DBs (sentinel +# present) skip — even if INITIAL_ADMIN_* env vars are set, we never +# re-create the admin (would clobber the existing one's password / +# Steam ID and silently lock the operator out of their own panel). # -# `SBPP_AUTO_INSTALL=0` opts OUT of this entirely (e.g. for an operator -# pointing the panel at a managed DB they've already populated by hand). +# `SBPP_AUTO_INSTALL=0` opts OUT of this entirely (e.g. for an +# operator pointing the panel at a managed DB they've already +# populated by hand, OR for the operator who wants to run the +# install wizard manually). In that mode we also SKIP the +# install/+updater/ strip in step 7 so the wizard surface stays +# reachable — see strip_install_dirs. first_boot_install() { if [ "$SBPP_AUTO_INSTALL" != "1" ]; then log "step 5: SBPP_AUTO_INSTALL=0 — skipping first-boot install (operator opted out)" + log "step 5: install/ + updater/ will NOT be stripped in step 7 either — the wizard is reachable at /install/" return fi - table="${DB_PREFIX}_admins" - exists="$(echo "SELECT 1 FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name='${table}' LIMIT 1;" | run_sql 2>/dev/null || true)" - if [ -n "$exists" ]; then - log "step 5: ${table} already exists — skipping first-boot install" + # MED-3 of the #1381 review: pre-fix the sentinel was "does the + # {prefix}_admins table exist?". That created a first-boot install + # race: struc.sql creates :prefix_admins *before* :prefix_settings + # is fully seeded by data.sql. If the entrypoint crashed mid- + # `data.sql` (e.g. an OOM), a restart would see the admins table + # present, skip the install pass, and the panel would boot against + # a half-seeded :prefix_settings — every Config::get(...) lookup + # returning the schema's column default. + # + # The correct sentinel is the `:prefix_settings.config.version` + # row, which is the second-to-last INSERT in data.sql — its + # presence means data.sql ran to completion. sb-db.php has-version-row + # treats a missing table (PDOException 42S02) as "not present" so + # a truly fresh DB still triggers the install pass. + if php "${SBPP_LIB_DIR}/sb-db.php" has-version-row "${DB_PREFIX}" 2>/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)" @@ -438,24 +610,31 @@ first_boot_install() { 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). + # 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 (or skip with a clear next-step nudge). - if [ -z "$INITIAL_ADMIN_NAME" ] \ - || [ -z "$INITIAL_ADMIN_STEAM" ] \ - || [ -z "$INITIAL_ADMIN_EMAIL" ] \ - || [ -z "$INITIAL_ADMIN_PASSWORD" ]; then - log "step 5: INITIAL_ADMIN_{NAME,STEAM,EMAIL,PASSWORD} not all set — admin row not seeded" - log "step 5: log in as the CONSOLE row only; seed an admin manually via the panel before going live" - return - fi - seed_initial_admin } @@ -486,13 +665,13 @@ seed_initial_admin() { # gid=-1, extraflags=16777216 (1<<24 = ADMIN_OWNER), immunity=100. # Same shape as page.5.php's INSERT. # - # The mysql client connects via the panel's runtime user/pass which - # was already verified by wait_for_db. Quoting: we pass values via - # `printf %q` shell-escape into a single-string SQL stmt; since the - # password hash and the admin name come from operator env vars, - # they're trusted by definition (the operator set them). The - # authid is regex-validated above. The email is validated by the - # panel UI later but here we just splat it in. + # 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" @@ -539,6 +718,21 @@ run_pending_migrations() { # 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' getMessage() . "\n"); + exit(1); +} + +$failed = false; foreach ($updater->getMessageStack() as $line) { - fwrite(STDERR, "[prod-entrypoint][step 6] " . strip_tags((string) $line) . "\n"); + $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" + die "updater run failed (exit $rc) — refusing to start panel against a partially-upgraded schema" fi } @@ -615,6 +833,26 @@ PHP # 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" } @@ -646,6 +884,7 @@ main() { 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 @@ -654,6 +893,43 @@ main() { 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; +} From 211d0714e8b421cb1a6cd4e262261af983861b23 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 21:17:51 -0400 Subject: [PATCH 08/13] fix(docker): drop default-mysql-client from prod runtime image (#1381 HIGH-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/Dockerfile.prod | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index 26ac3fe49..daf3e6973 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -101,6 +101,17 @@ ENV DEBIAN_FRONTEND=noninteractive \ # 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 \ @@ -111,7 +122,6 @@ RUN apt-get update \ libzip-dev \ libonig-dev \ libgmp-dev \ - default-mysql-client \ tzdata \ && docker-php-ext-install -j"$(nproc)" \ pdo_mysql \ @@ -122,12 +132,6 @@ RUN apt-get update \ && a2enmod rewrite headers remoteip setenvif \ && rm -rf /var/lib/apt/lists/* -# `default-mysql-client` is in the runtime image ONLY for the entrypoint's -# `mysqladmin ping` wait-for-DB loop. The 22 MB cost is worth not -# re-implementing the wait loop in PHP (which would need a temporary -# script staged before init.php is reachable). The client is unused at -# request time and never exposed to the panel. - # 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 @@ -201,6 +205,14 @@ RUN mkdir -p \ /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 From 1b157f1b9690a3c92e54b3c5e7c2bb0b46a6ba09 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 21:19:52 -0400 Subject: [PATCH 09/13] fix(health): stop leaking PDO exception text + skip telemetry on probes (#1381 CRIT-3 + MED-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- web/health.php | 71 ++++++------------- web/includes/Telemetry/Telemetry.php | 12 ++++ web/tests/integration/TelemetryOptOutTest.php | 36 ++++++++++ 3 files changed, 69 insertions(+), 50 deletions(-) diff --git a/web/health.php b/web/health.php index b309eecf3..7cebe9350 100644 --- a/web/health.php +++ b/web/health.php @@ -3,76 +3,47 @@ // SourceBans++ healthcheck endpoint (#1381 deliverable 5b). // -// 200 OK on a successful `SELECT 1` round-trip against the panel's DB, -// 503 on any failure. Plain-text body, deliberately minimal — this is -// the URL the Docker HEALTHCHECK / Kubernetes liveness-probe / app -// platform router calls to decide if the container is healthy. +// 200 OK on a successful `SELECT 1` against the panel's DB, 503 +// on any failure. What Docker HEALTHCHECK / k8s liveness probes / +// app-platform routers hit every ~30s. // -// Bypasses the panel chrome: -// - No Smarty render. -// - No CSRF / Auth / UserManager bootstrap. -// - No telemetry tick (the `register_shutdown_function` in init.php -// would fire a cURL POST after every healthcheck, which is wrong: -// the orchestrator hits this endpoint every ~30s). +// Two contracts this endpoint MUST honour (#1381 review): // -// We DO load `init.php` because: -// - it owns the load-bearing PSR-4 + class-alias bootstrap that the -// `Sbpp\Db\Database` constructor depends on, -// - it carries the install/+updater/-presence guard contract; the -// prod entrypoint's `step 7` rm -rf is what makes the guard pass, -// so by the time Apache serves traffic this file's init.php -// load is on the happy path. +// * CRIT-3: NEVER echo `$e->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. // -// We then cancel the telemetry shutdown function we never want to fire -// from a healthcheck (the panel registers one in init.php — fine for -// real page loads; counterproductive for a probe that runs every 30s). +// * 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. -// Trim the footprint immediately. By the time init.php finishes -// loading the chrome it's started Auth (which calls session_start) -// and a partial template tree. We unwind everything not needed for -// the SELECT 1 below. +define('SBPP_SKIP_TELEMETRY', true); ini_set('display_errors', '0'); ini_set('html_errors', '0'); require_once __DIR__ . '/init.php'; -// Skip the telemetry shutdown — registered by init.php near the end. -// Healthchecks are high-frequency; a per-probe cURL POST would be -// pure noise. The panel's tickIfDue() is rate-limited to one ping per -// 24h regardless, so the probe wouldn't actually fire one — but -// removing it explicitly avoids the slot-reservation UPDATE on every -// healthcheck. -// -// `register_shutdown_function` doesn't expose an unregister API, so -// the next-best is to noop it via the panel's own contract: -// `\Sbpp\Telemetry\Telemetry::tickIfDue` wraps its body in -// try/catch(\Throwable), so even if it ran, it can't 5xx the response. - header('Content-Type: text/plain; charset=utf-8'); header('Cache-Control: no-store'); header('X-Robots-Tag: noindex'); try { - /** @var \Sbpp\Db\Database $db */ - $db = $GLOBALS['PDO']; - $db->query('SELECT 1'); - $row = $db->single(); - + $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; } - - http_response_code(503); - echo "FAIL: unexpected SELECT 1 result\n"; error_log('[health.php] SELECT 1 returned unexpected shape'); - exit; } catch (\Throwable $e) { - http_response_code(503); - echo "FAIL: " . $e->getMessage() . "\n"; - // Echo to stderr too — the orchestrator captures it via the - // container's log stream. error_log('[health.php] DB probe failed: ' . $e->getMessage()); - exit; } +http_response_code(503); +echo "FAIL\n"; 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/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'); From 841bb098b48349e6ea4c192b056c46a2825006c1 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 21:22:36 -0400 Subject: [PATCH 10/13] fix(auth): gate Host::isSecure XFP on SBPP_TRUSTED_PROXIES (#1381 CRIT-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/includes/Auth/Host.php | 127 +++++++++++++- web/init.php | 21 +++ web/tests/unit/Auth/HostIsSecureTest.php | 211 +++++++++++++++++++++++ 3 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 web/tests/unit/Auth/HostIsSecureTest.php 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/init.php b/web/init.php index fdb63289e..dd976e34b 100644 --- a/web/init.php +++ b/web/init.php @@ -68,6 +68,27 @@ } 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 // reachable via a `localhost` Host header (port-forward, SSH 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()); + } +} From 440e4b2e7227a9fb8c8a530b46a3643cbc9902f1 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 21:24:24 -0400 Subject: [PATCH 11/13] fix(panel): honour SBPP_CONFIG_PATH in panel + install wizard (#1381 HIGH-1 + HIGH-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- web/index.php | 7 ++++++- web/install/bootstrap.php | 10 ++++++++++ web/install/pages/page.5.php | 21 +++++++++++++++++++-- web/phpstan-baseline.neon | 6 ------ 4 files changed, 35 insertions(+), 9 deletions(-) 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/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 From e01fb46703eecf5256b7905517f891c6d645d507 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 21:25:46 -0400 Subject: [PATCH 12/13] ci(docker): fix trigger AND-collapse + extend PR paths + drop COSIGN_EXPERIMENTAL (#1381 MED-6 + HIGH-4 + NIT-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .github/workflows/docker-image.yml | 61 +++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 7d64d66d4..b9bd64818 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -24,40 +24,60 @@ name: Docker image # --certificate-oidc-issuer=https://token.actions.githubusercontent.com` # without any pre-shared key. # -# Path filter scope: -# - docker/Dockerfile.prod, docker/php/prod-*, docker/apache/sbpp-prod.conf +# 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 / init-recovery.php — the SBPP_CONFIG_PATH plumbing -# that the entrypoint relies on -# - .github/workflows/docker-image.yml — touch-the-workflow trigger +# - 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 # -# We deliberately don't trigger on every web/** edit (the panel 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. +# 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' - - '.github/workflows/docker-image.yml' - tags: - - '*.*.*' - pull_request: - paths: - - 'docker/Dockerfile.prod' - - 'docker/php/prod-*' - - 'docker/apache/sbpp-prod.conf' + - 'web/install/includes/sql/**' + - 'web/includes/Auth/Host.php' + - 'web/includes/Telemetry/Telemetry.php' - '.github/workflows/docker-image.yml' workflow_dispatch: @@ -176,7 +196,12 @@ jobs: - name: Sign image with cosign (keyless) if: github.event_name != 'pull_request' env: - COSIGN_EXPERIMENTAL: '1' + # 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: | From 9a7237610f17ff40ef05e26cc0148794d31e5b3a Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Fri, 15 May 2026 21:26:23 -0400 Subject: [PATCH 13/13] docs(docker): warn about PORT vs SBPP_HOST_PORT compose mismatch (#1381 LOW-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .env.example.prod | 19 +++++++++++++++++++ .../getting-started/quickstart-docker.mdx | 10 ++++++++++ 2 files changed, 29 insertions(+) diff --git a/.env.example.prod b/.env.example.prod index 95a907a70..8cf514343 100644 --- a/.env.example.prod +++ b/.env.example.prod @@ -141,6 +141,25 @@ DATABASE_URL= # 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 diff --git a/docs/src/content/docs/getting-started/quickstart-docker.mdx b/docs/src/content/docs/getting-started/quickstart-docker.mdx index 93219e4ad..e530924e7 100644 --- a/docs/src/content/docs/getting-started/quickstart-docker.mdx +++ b/docs/src/content/docs/getting-started/quickstart-docker.mdx @@ -352,6 +352,16 @@ 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.