From d9774d29684987deb2b9a7f4a2af135af329a722 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:20:13 +0100 Subject: [PATCH] Fix: Prevent version downgrades and centralize CDN configuration (#7383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Root Cause Between Nov 25-26, a CDN redirect was added without curl's `-L` flag, causing version cache corruption and automatic downgrades. ## Three Critical Bugs Fixed ### Bug #1: CheckForUpdatesJob could overwrite newer cached version - Problem: CDN serving older version would overwrite local cache - Solution: Smart version merge - keep max Coolify version, update other components - Location: app/Jobs/CheckForUpdatesJob.php:33-52 ### Bug #2: Manual updates bypassed downgrade protection - Problem: Downgrade guard only applied to auto-updates - Solution: Always block downgrades for both manual and auto-updates - Location: app/Actions/Server/UpdateCoolify.php:65-75 ### Bug #3: Updates used stale local cache - Problem: Never validated cache against CDN at update time - Solution: Fetch fresh CDN data before executing updates - Location: app/Actions/Server/UpdateCoolify.php:34-49 ## Additional Improvement: Centralized CDN Configuration Added three new config keys for easy CDN management: - `cdn_url` - Base CDN URL (default: https://cdn.coollabs.io) - `versions_url` - Full versions.json URL - `upgrade_script_url` - Full upgrade.sh URL All configurable via environment variables: ```bash CDN_URL=https://cdn.coolify.io VERSIONS_URL=https://custom-cdn.example.com/versions.json UPGRADE_SCRIPT_URL=https://custom-cdn.example.com/upgrade.sh ``` ## Files Modified - config/constants.php - CDN configuration - app/Jobs/CheckForUpdatesJob.php - Smart version merge + centralized URL - app/Actions/Server/UpdateCoolify.php - Downgrade protection + fresh fetch + centralized URLs - app/Jobs/CheckHelperImageJob.php - Centralized URL - bootstrap/helpers/shared.php - Centralized URL ## Testing - ✅ All modified files pass Pint formatting - ✅ 78 unit tests pass (2 pre-existing failures unrelated to changes) ## Impact - No breaking changes - defaults to current CDN - Easy CDN migration via environment variables - Prevents all downgrade scenarios - Maintains independent Sentinel/Helper/Traefik updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Actions/Server/UpdateCoolify.php | 39 ++++++++++++++++++++++++++-- app/Jobs/CheckForUpdatesJob.php | 35 +++++++++++++++++++++---- app/Jobs/CheckHelperImageJob.php | 2 +- bootstrap/helpers/shared.php | 2 +- config/constants.php | 3 +++ 5 files changed, 72 insertions(+), 9 deletions(-) diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index 0bf763d78..faf733a10 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -3,6 +3,8 @@ namespace App\Actions\Server; use App\Models\Server; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Sleep; use Lorisleiva\Actions\Concerns\AsAction; @@ -29,7 +31,25 @@ public function handle($manual_update = false) return; } CleanupDocker::dispatch($this->server, false, false); - $this->latestVersion = get_latest_version_of_coolify(); + + // Fetch fresh version from CDN instead of using cache + try { + $response = Http::retry(3, 1000)->timeout(10) + ->get(config('constants.coolify.versions_url')); + + if ($response->successful()) { + $versions = $response->json(); + $this->latestVersion = data_get($versions, 'coolify.v4.version'); + } else { + // Fallback to cache if CDN unavailable + Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using cache'); + $this->latestVersion = get_latest_version_of_coolify(); + } + } catch (\Throwable $e) { + Log::warning('Failed to fetch fresh version from CDN, using cache', ['error' => $e->getMessage()]); + $this->latestVersion = get_latest_version_of_coolify(); + } + $this->currentVersion = config('constants.coolify.version'); if (! $manual_update) { if (! $settings->is_auto_update_enabled) { @@ -42,6 +62,20 @@ public function handle($manual_update = false) return; } } + + // ALWAYS check for downgrades (even for manual updates) + if (version_compare($this->latestVersion, $this->currentVersion, '<')) { + Log::error('Downgrade prevented', [ + 'target_version' => $this->latestVersion, + 'current_version' => $this->currentVersion, + 'manual_update' => $manual_update, + ]); + throw new \Exception( + "Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ". + 'If you need to downgrade, please do so manually via Docker commands.' + ); + } + $this->update(); $settings->new_version_available = false; $settings->save(); @@ -56,8 +90,9 @@ private function update() $image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion; instant_remote_process(["docker pull -q $image"], $this->server, false); + $upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); remote_process([ - 'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh', + "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh", "bash /data/coolify/source/upgrade.sh $this->latestVersion", ], $this->server); } diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index 4f2bfa68c..0d2906968 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -10,6 +10,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue { @@ -22,20 +23,44 @@ public function handle(): void return; } $settings = instanceSettings(); - $response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json'); + $response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url')); if ($response->successful()) { $versions = $response->json(); $latest_version = data_get($versions, 'coolify.v4.version'); $current_version = config('constants.coolify.version'); + // Read existing cached version + $existingVersions = null; + $existingCoolifyVersion = null; + if (File::exists(base_path('versions.json'))) { + $existingVersions = json_decode(File::get(base_path('versions.json')), true); + $existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version'); + } + + // Detect CDN serving older Coolify version + if ($existingCoolifyVersion && version_compare($latest_version, $existingCoolifyVersion, '<')) { + Log::warning('CDN served older Coolify version', [ + 'cdn_version' => $latest_version, + 'cached_version' => $existingCoolifyVersion, + 'current_version' => $current_version, + ]); + + // Keep the NEWER Coolify version from cache, but update other components + $versions['coolify']['v4']['version'] = $existingCoolifyVersion; + $latest_version = $existingCoolifyVersion; + } + + // ALWAYS write versions.json (for Sentinel, Helper, Traefik updates) + File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT)); + + // Invalidate cache to ensure fresh data is loaded + invalidate_versions_cache(); + + // Only mark new version available if Coolify version actually increased if (version_compare($latest_version, $current_version, '>')) { // New version available $settings->update(['new_version_available' => true]); - File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT)); - - // Invalidate cache to ensure fresh data is loaded - invalidate_versions_cache(); } else { $settings->update(['new_version_available' => false]); } diff --git a/app/Jobs/CheckHelperImageJob.php b/app/Jobs/CheckHelperImageJob.php index 6abb8a150..6d76da8eb 100644 --- a/app/Jobs/CheckHelperImageJob.php +++ b/app/Jobs/CheckHelperImageJob.php @@ -21,7 +21,7 @@ public function __construct() {} public function handle(): void { try { - $response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json'); + $response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url')); if ($response->successful()) { $versions = $response->json(); $settings = instanceSettings(); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 1b23247fa..1066f1a63 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -230,7 +230,7 @@ function get_route_parameters(): array function get_latest_sentinel_version(): string { try { - $response = Http::get('https://cdn.coollabs.io/coolify/versions.json'); + $response = Http::get(config('constants.coolify.versions_url')); $versions = $response->json(); return data_get($versions, 'coolify.sentinel.version'); diff --git a/config/constants.php b/config/constants.php index 893fb11fd..9b1dd5f68 100644 --- a/config/constants.php +++ b/config/constants.php @@ -12,6 +12,9 @@ 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), + 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), + 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), + 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'releases_url' => 'https://cdn.coolify.io/releases.json', ],