From 0219e3c987ca9e1eefac6d569c655c8dc43e25ca Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 23:25:06 +0200 Subject: [PATCH] fix(deploy): recreate the container, keep the root password secret, forget failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update.sh recreated the app container only at the very end, so an update that changes the PHP runtime would have installed dependencies and migrated inside the old one. - install.sh randomised DB_PASSWORD but left DB_ROOT_PASSWORD at the value committed in .env.example — the same root credential on every installation, reachable from any container on the compose network. - Settings cached the fallback after a database failure, so one blip could leave a hidden site publicly visible until someone cleared the cache. Only a successful read is cached now, proven by a test that drops the table and brings it back. Co-Authored-By: Claude Opus 4.8 --- app/Support/Settings.php | 17 ++++++++++++----- deploy/install.sh | 3 +++ deploy/update.sh | 8 ++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/Support/Settings.php b/app/Support/Settings.php index 55ea628..76bb8a7 100644 --- a/app/Support/Settings.php +++ b/app/Support/Settings.php @@ -20,25 +20,32 @@ final class Settings public static function get(string $key, mixed $default = null): mixed { - $raw = Cache::rememberForever(self::CACHE_PREFIX.$key, function () use ($key) { + $cacheKey = self::CACHE_PREFIX.$key; + + if (Cache::has($cacheKey)) { + $raw = Cache::get($cacheKey); + } else { try { $row = DB::table('app_settings')->where('key', $key)->value('value'); } catch (QueryException $e) { // The gate reads a setting on every request, so an unreachable // or not-yet-migrated table would take the whole site down — // including during a deploy where new code runs before migrate. - // Fall back to the caller's default instead, and say so. + // Fall back to the caller's default, but do NOT cache that: + // caching a failure would keep a hidden site publicly visible + // long after the database came back. Log::warning('app_settings unavailable, using default', [ 'key' => $key, 'error' => $e->getMessage(), ]); - return '__missing__'; + return $default; } // Sentinel: distinguishes "stored null" from "never stored", so a // missing row does not get re-queried on every request. - return $row === null ? '__missing__' : $row; - }); + $raw = $row ?? '__missing__'; + Cache::forever($cacheKey, $raw); + } return $raw === '__missing__' ? $default : json_decode($raw, true); } diff --git a/deploy/install.sh b/deploy/install.sh index ac8ea47..27cda5d 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -133,6 +133,9 @@ else set_env APP_KEY "base64:$(rand_b64 32)" set_env DB_PASSWORD "$(rand_hex 24)" + # Otherwise every installation shares the value from .env.example, reachable + # from any container on the compose network. + set_env DB_ROOT_PASSWORD "$(rand_hex 24)" set_env VPN_CONFIG_KEY "$(rand_b64 32)" set_env REVERB_APP_ID "clupilot" diff --git a/deploy/update.sh b/deploy/update.sh index 59723a0..036272a 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -68,6 +68,14 @@ after="$(git rev-parse HEAD)" if ! git diff --quiet "$before" "$after" -- docker/ 2>/dev/null; then log "Rebuilding the image" docker compose build --quiet app + # Recreate now, not at the end: everything below runs INSIDE this container, + # and an update that changes the PHP runtime would otherwise install and + # migrate under the old one. + docker compose up -d --force-recreate app + for _ in $(seq 1 30); do + in_app php -v >/dev/null 2>&1 && break + sleep 2 + done fi # vendor/ and node_modules/ live in the bind mount, so they shadow whatever the