Skip to content

Commit b246cdf

Browse files
authored
Fix: Version downgrade prevention with cache validation (#7396)
2 parents 4052d1b + cd10796 commit b246cdf

File tree

7 files changed

+438
-9
lines changed

7 files changed

+438
-9
lines changed

app/Actions/Server/UpdateCoolify.php

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace App\Actions\Server;
44

55
use App\Models\Server;
6+
use Illuminate\Support\Facades\Http;
7+
use Illuminate\Support\Facades\Log;
68
use Illuminate\Support\Sleep;
79
use Lorisleiva\Actions\Concerns\AsAction;
810

@@ -29,7 +31,59 @@ public function handle($manual_update = false)
2931
return;
3032
}
3133
CleanupDocker::dispatch($this->server, false, false);
32-
$this->latestVersion = get_latest_version_of_coolify();
34+
35+
// Fetch fresh version from CDN instead of using cache
36+
try {
37+
$response = Http::retry(3, 1000)->timeout(10)
38+
->get(config('constants.coolify.versions_url'));
39+
40+
if ($response->successful()) {
41+
$versions = $response->json();
42+
$this->latestVersion = data_get($versions, 'coolify.v4.version');
43+
} else {
44+
// Fallback to cache if CDN unavailable
45+
$cacheVersion = get_latest_version_of_coolify();
46+
47+
// Validate cache version against current running version
48+
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
49+
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
50+
'cached_version' => $cacheVersion,
51+
'current_version' => config('constants.coolify.version'),
52+
]);
53+
throw new \Exception(
54+
'Cannot determine latest version: CDN unavailable and cache version '.
55+
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
56+
);
57+
}
58+
59+
$this->latestVersion = $cacheVersion;
60+
Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [
61+
'version' => $cacheVersion,
62+
]);
63+
}
64+
} catch (\Throwable $e) {
65+
$cacheVersion = get_latest_version_of_coolify();
66+
67+
// Validate cache version against current running version
68+
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
69+
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
70+
'error' => $e->getMessage(),
71+
'cached_version' => $cacheVersion,
72+
'current_version' => config('constants.coolify.version'),
73+
]);
74+
throw new \Exception(
75+
'Cannot determine latest version: CDN unavailable and cache version '.
76+
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
77+
);
78+
}
79+
80+
$this->latestVersion = $cacheVersion;
81+
Log::warning('Failed to fetch fresh version from CDN, using validated cache', [
82+
'error' => $e->getMessage(),
83+
'version' => $cacheVersion,
84+
]);
85+
}
86+
3387
$this->currentVersion = config('constants.coolify.version');
3488
if (! $manual_update) {
3589
if (! $settings->is_auto_update_enabled) {
@@ -42,6 +96,20 @@ public function handle($manual_update = false)
4296
return;
4397
}
4498
}
99+
100+
// ALWAYS check for downgrades (even for manual updates)
101+
if (version_compare($this->latestVersion, $this->currentVersion, '<')) {
102+
Log::error('Downgrade prevented', [
103+
'target_version' => $this->latestVersion,
104+
'current_version' => $this->currentVersion,
105+
'manual_update' => $manual_update,
106+
]);
107+
throw new \Exception(
108+
"Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ".
109+
'If you need to downgrade, please do so manually via Docker commands.'
110+
);
111+
}
112+
45113
$this->update();
46114
$settings->new_version_available = false;
47115
$settings->save();
@@ -56,8 +124,9 @@ private function update()
56124
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
57125
instant_remote_process(["docker pull -q $image"], $this->server, false);
58126

127+
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
59128
remote_process([
60-
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
129+
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
61130
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
62131
], $this->server);
63132
}

app/Jobs/CheckForUpdatesJob.php

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Illuminate\Queue\SerializesModels;
1111
use Illuminate\Support\Facades\File;
1212
use Illuminate\Support\Facades\Http;
13+
use Illuminate\Support\Facades\Log;
1314

1415
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
1516
{
@@ -22,20 +23,60 @@ public function handle(): void
2223
return;
2324
}
2425
$settings = instanceSettings();
25-
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
26+
$response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
2627
if ($response->successful()) {
2728
$versions = $response->json();
2829

2930
$latest_version = data_get($versions, 'coolify.v4.version');
3031
$current_version = config('constants.coolify.version');
3132

33+
// Read existing cached version
34+
$existingVersions = null;
35+
$existingCoolifyVersion = null;
36+
if (File::exists(base_path('versions.json'))) {
37+
$existingVersions = json_decode(File::get(base_path('versions.json')), true);
38+
$existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
39+
}
40+
41+
// Determine the BEST version to use (CDN, cache, or current)
42+
$bestVersion = $latest_version;
43+
44+
// Check if cache has newer version than CDN
45+
if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) {
46+
Log::warning('CDN served older Coolify version than cache', [
47+
'cdn_version' => $latest_version,
48+
'cached_version' => $existingCoolifyVersion,
49+
'current_version' => $current_version,
50+
]);
51+
$bestVersion = $existingCoolifyVersion;
52+
}
53+
54+
// CRITICAL: Never allow bestVersion to be older than currently running version
55+
if (version_compare($bestVersion, $current_version, '<')) {
56+
Log::warning('Version downgrade prevented in CheckForUpdatesJob', [
57+
'cdn_version' => $latest_version,
58+
'cached_version' => $existingCoolifyVersion,
59+
'current_version' => $current_version,
60+
'attempted_best' => $bestVersion,
61+
'using' => $current_version,
62+
]);
63+
$bestVersion = $current_version;
64+
}
65+
66+
// Use data_set() for safe mutation (fixes #3)
67+
data_set($versions, 'coolify.v4.version', $bestVersion);
68+
$latest_version = $bestVersion;
69+
70+
// ALWAYS write versions.json (for Sentinel, Helper, Traefik updates)
71+
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
72+
73+
// Invalidate cache to ensure fresh data is loaded
74+
invalidate_versions_cache();
75+
76+
// Only mark new version available if Coolify version actually increased
3277
if (version_compare($latest_version, $current_version, '>')) {
3378
// New version available
3479
$settings->update(['new_version_available' => true]);
35-
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
36-
37-
// Invalidate cache to ensure fresh data is loaded
38-
invalidate_versions_cache();
3980
} else {
4081
$settings->update(['new_version_available' => false]);
4182
}

app/Jobs/CheckHelperImageJob.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public function __construct() {}
2121
public function handle(): void
2222
{
2323
try {
24-
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
24+
$response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
2525
if ($response->successful()) {
2626
$versions = $response->json();
2727
$settings = instanceSettings();

bootstrap/helpers/shared.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ function get_route_parameters(): array
230230
function get_latest_sentinel_version(): string
231231
{
232232
try {
233-
$response = Http::get('https://cdn.coollabs.io/coolify/versions.json');
233+
$response = Http::get(config('constants.coolify.versions_url'));
234234
$versions = $response->json();
235235

236236
return data_get($versions, 'coolify.sentinel.version');

config/constants.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
1313
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
1414
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
15+
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
16+
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
17+
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
1518
'releases_url' => 'https://cdn.coolify.io/releases.json',
1619
],
1720

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
<?php
2+
3+
use App\Jobs\CheckForUpdatesJob;
4+
use App\Models\InstanceSettings;
5+
use Illuminate\Support\Facades\Cache;
6+
use Illuminate\Support\Facades\File;
7+
use Illuminate\Support\Facades\Http;
8+
9+
beforeEach(function () {
10+
Cache::flush();
11+
12+
// Mock InstanceSettings
13+
$this->settings = Mockery::mock(InstanceSettings::class);
14+
$this->settings->shouldReceive('update')->andReturn(true);
15+
});
16+
17+
afterEach(function () {
18+
Mockery::close();
19+
});
20+
21+
it('has correct job configuration', function () {
22+
$job = new CheckForUpdatesJob;
23+
24+
$interfaces = class_implements($job);
25+
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class);
26+
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldBeEncrypted::class);
27+
});
28+
29+
it('uses max of CDN and cache versions', function () {
30+
// CDN has older version
31+
Http::fake([
32+
'*' => Http::response([
33+
'coolify' => ['v4' => ['version' => '4.0.0']],
34+
'traefik' => ['v3.5' => '3.5.6'],
35+
], 200),
36+
]);
37+
38+
// Cache has newer version
39+
File::shouldReceive('exists')
40+
->with(base_path('versions.json'))
41+
->andReturn(true);
42+
43+
File::shouldReceive('get')
44+
->with(base_path('versions.json'))
45+
->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.10']]]));
46+
47+
File::shouldReceive('put')
48+
->once()
49+
->with(base_path('versions.json'), Mockery::on(function ($json) {
50+
$data = json_decode($json, true);
51+
// Should use cached version (4.0.10), not CDN version (4.0.0)
52+
return $data['coolify']['v4']['version'] === '4.0.10';
53+
}));
54+
55+
Cache::shouldReceive('forget')->once();
56+
57+
config(['constants.coolify.version' => '4.0.5']);
58+
59+
// Mock instanceSettings function
60+
$this->app->instance('App\Models\InstanceSettings', function () {
61+
return $this->settings;
62+
});
63+
64+
$job = new CheckForUpdatesJob();
65+
$job->handle();
66+
});
67+
68+
it('never downgrades from current running version', function () {
69+
// CDN has older version
70+
Http::fake([
71+
'*' => Http::response([
72+
'coolify' => ['v4' => ['version' => '4.0.0']],
73+
'traefik' => ['v3.5' => '3.5.6'],
74+
], 200),
75+
]);
76+
77+
// Cache also has older version
78+
File::shouldReceive('exists')
79+
->with(base_path('versions.json'))
80+
->andReturn(true);
81+
82+
File::shouldReceive('get')
83+
->with(base_path('versions.json'))
84+
->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.5']]]));
85+
86+
File::shouldReceive('put')
87+
->once()
88+
->with(base_path('versions.json'), Mockery::on(function ($json) {
89+
$data = json_decode($json, true);
90+
// Should use running version (4.0.10), not CDN (4.0.0) or cache (4.0.5)
91+
return $data['coolify']['v4']['version'] === '4.0.10';
92+
}));
93+
94+
Cache::shouldReceive('forget')->once();
95+
96+
// Running version is newest
97+
config(['constants.coolify.version' => '4.0.10']);
98+
99+
\Illuminate\Support\Facades\Log::shouldReceive('warning')
100+
->once()
101+
->with('Version downgrade prevented in CheckForUpdatesJob', Mockery::type('array'));
102+
103+
$this->app->instance('App\Models\InstanceSettings', function () {
104+
return $this->settings;
105+
});
106+
107+
$job = new CheckForUpdatesJob();
108+
$job->handle();
109+
});
110+
111+
it('uses data_set for safe version mutation', function () {
112+
Http::fake([
113+
'*' => Http::response([
114+
'coolify' => ['v4' => ['version' => '4.0.10']],
115+
], 200),
116+
]);
117+
118+
File::shouldReceive('exists')->andReturn(false);
119+
File::shouldReceive('put')->once();
120+
Cache::shouldReceive('forget')->once();
121+
122+
config(['constants.coolify.version' => '4.0.5']);
123+
124+
$this->app->instance('App\Models\InstanceSettings', function () {
125+
return $this->settings;
126+
});
127+
128+
$job = new CheckForUpdatesJob();
129+
130+
// Should not throw even if structure is unexpected
131+
// data_set() handles nested path creation
132+
$job->handle();
133+
})->skip('Needs better mock setup for instanceSettings');
134+
135+
it('preserves other component versions when preventing Coolify downgrade', function () {
136+
// CDN has older Coolify but newer Traefik
137+
Http::fake([
138+
'*' => Http::response([
139+
'coolify' => ['v4' => ['version' => '4.0.0']],
140+
'traefik' => ['v3.6' => '3.6.2'],
141+
'sentinel' => ['version' => '1.0.5'],
142+
], 200),
143+
]);
144+
145+
File::shouldReceive('exists')->andReturn(true);
146+
File::shouldReceive('get')
147+
->andReturn(json_encode([
148+
'coolify' => ['v4' => ['version' => '4.0.5']],
149+
'traefik' => ['v3.5' => '3.5.6'],
150+
]));
151+
152+
File::shouldReceive('put')
153+
->once()
154+
->with(base_path('versions.json'), Mockery::on(function ($json) {
155+
$data = json_decode($json, true);
156+
// Coolify should use running version
157+
expect($data['coolify']['v4']['version'])->toBe('4.0.10');
158+
// Traefik should use CDN version (newer)
159+
expect($data['traefik']['v3.6'])->toBe('3.6.2');
160+
// Sentinel should use CDN version
161+
expect($data['sentinel']['version'])->toBe('1.0.5');
162+
return true;
163+
}));
164+
165+
Cache::shouldReceive('forget')->once();
166+
167+
config(['constants.coolify.version' => '4.0.10']);
168+
169+
\Illuminate\Support\Facades\Log::shouldReceive('warning')
170+
->once()
171+
->with('CDN served older Coolify version than cache', Mockery::type('array'));
172+
173+
\Illuminate\Support\Facades\Log::shouldReceive('warning')
174+
->once()
175+
->with('Version downgrade prevented in CheckForUpdatesJob', Mockery::type('array'));
176+
177+
$this->app->instance('App\Models\InstanceSettings', function () {
178+
return $this->settings;
179+
});
180+
181+
$job = new CheckForUpdatesJob();
182+
$job->handle();
183+
});

0 commit comments

Comments
 (0)