From f7ea96214505d577cbe901deeec53717b234baea Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:57:01 +0100 Subject: [PATCH] fix(server): enable disabled self-hosted servers again on validation On self-hosted, a validation that connects now clears force_disabled. The unreachable cleanup skips the localhost server, and the server page explains why a server is disabled. Cloud and transferred servers stay disabled. Closes #12475 --- .../Commands/CleanupUnreachableServers.php | 3 +- app/Models/Server.php | 17 +++++ .../views/livewire/server/show.blade.php | 8 ++ .../ForceDisabledServerReEnableTest.php | 74 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/ForceDisabledServerReEnableTest.php diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index 615dc21d20..f6b3ee9f6f 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -15,7 +15,8 @@ class CleanupUnreachableServers extends Command public function handle() { echo "Running unreachable server cleanup...\n"; - $servers = Server::where('unreachable_count', '>=', 3)->whereHas('notificationThrottles', fn ($query) => $query->where('notification', Unreachable::class))->where('updated_at', '<', now()->subDays(7))->get(); + // The localhost server is the Coolify host itself, so the cleanup never touches it. + $servers = Server::where('id', '!=', 0)->where('unreachable_count', '>=', 3)->whereHas('notificationThrottles', fn ($query) => $query->where('notification', Unreachable::class))->where('updated_at', '<', now()->subDays(7))->get(); if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; diff --git a/app/Models/Server.php b/app/Models/Server.php index 736d63aed5..2ac649ce98 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1438,6 +1438,15 @@ $siteAddress { return $this->settings->force_disabled; } + /** + * On self-hosted, only the unreachable cleanup disables a server, so a working connection can enable it again. + * On Cloud the flag enforces the plan server limit, and a transferred server stays an archive. + */ + public function canBeReEnabled(): bool + { + return $this->settings->force_disabled === true && ! isCloud() && ! $this->isTransferredAway(); + } + /** * Server was migrated away from this Coolify instance (source side). * Must not be revalidated or re-enabled as a live managed host. @@ -2544,6 +2553,11 @@ $siteAddress { public function validateConnection(bool $justCheckingNewKey = false) { + // A validation enables a disabled self-hosted server again, but only when the connection works. + $isReEnabling = $this->canBeReEnabled() && ! $this->hasPlaceholderIp(); + if ($isReEnabling) { + $this->forceEnableServer(); + } if ($this->skipServer()) { return ['uptime' => false, 'error' => 'Server skipped.']; } @@ -2564,6 +2578,9 @@ $siteAddress { return ['uptime' => true, 'error' => null]; } catch (\Throwable $e) { + if ($isReEnabling) { + $this->forceDisableServer(); + } $error = $e->getMessage(); if ($error === SshMultiplexingHelper::HOST_KEY_CHANGED_MESSAGE && ! $this->recordHostKeyMismatch()) { $error = 'SSH host key verification failed, but the server still sends the trusted host key.'; diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 31b6fae612..938725d50f 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -221,6 +221,14 @@ @endif + @if ($server->canBeReEnabled()) + + Coolify disabled this server because it was unreachable for more than 7 days. Coolify does + not connect to it. Correct the connection settings, then click + Validate connection. When the connection works, the server is enabled again. + + @endif +
diff --git a/tests/Feature/ForceDisabledServerReEnableTest.php b/tests/Feature/ForceDisabledServerReEnableTest.php new file mode 100644 index 0000000000..6218a61b1b --- /dev/null +++ b/tests/Feature/ForceDisabledServerReEnableTest.php @@ -0,0 +1,74 @@ + 0]); + config(['constants.coolify.self_hosted' => true, 'constants.ssh.mux_enabled' => false]); + Storage::fake('ssh-keys'); + + $team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $this->server->forceDisableServer(); +}); + +it('enables a disabled self-hosted server again when the validation connects', function () { + Process::fake(); + + $result = $this->server->validateConnection(); + + expect($result)->toBe(['uptime' => true, 'error' => null]) + ->and($this->server->settings->fresh()->force_disabled)->toBeFalse() + ->and($this->server->settings->fresh()->is_reachable)->toBeTrue(); +}); + +it('keeps a self-hosted server disabled when the validation cannot connect', function () { + Process::fake(['*' => Process::result(errorOutput: 'ssh: connect to host 192.0.2.1 port 22: Connection refused', exitCode: 255)]); + + $result = $this->server->validateConnection(); + + expect($result['uptime'])->toBeFalse() + ->and($result['error'])->not->toBe('Server skipped.') + ->and($this->server->settings->fresh()->force_disabled)->toBeTrue(); +}); + +it('does not enable a disabled server on cloud, where the flag enforces the plan limit', function () { + config(['constants.coolify.self_hosted' => false]); + Process::fake(); + + expect($this->server->validateConnection())->toBe(['uptime' => false, 'error' => 'Server skipped.']) + ->and($this->server->settings->fresh()->force_disabled)->toBeTrue(); + Process::assertNothingRan(); +}); + +it('does not enable the archive of a transferred server', function () { + $this->server->update(['server_metadata' => ['transfer' => ['status' => 'transferred']]]); + Process::fake(); + + expect($this->server->validateConnection())->toBe(['uptime' => false, 'error' => 'Server skipped.']) + ->and($this->server->settings->fresh()->force_disabled)->toBeTrue(); + Process::assertNothingRan(); +}); + +it('never disables the localhost server in the unreachable cleanup', function () { + $localhost = Server::factory()->create(['id' => 0, 'team_id' => $this->server->team_id, 'unreachable_count' => 50]); + $localhost->forceFill(['updated_at' => now()->subDays(8)])->saveQuietly(); + NotificationThrottle::record($localhost, Unreachable::class); + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + expect($localhost->settings->fresh()->force_disabled)->toBeFalse(); +});