feat(server): add dev-only toggle for server management ownership

Add a Management ownership switch to the server page, shown only in
development, that lets a user with update permission release or claim
control of a server from this Coolify instance. Claiming is blocked for
localhost and when the team is over its server limit. Both actions are
recorded in the audit log.

When ServerTransferClaimer claims a server, it now clears force_disabled
if the team is within its server limit, and it always saves settings,
not only when the sentinel is rebound.
This commit is contained in:
Andras Bacsai
2026-09-28 12:14:56 +02:00
parent 350d8a6571
commit 4ab0e89e24
4 changed files with 256 additions and 1 deletions
+40
View File
@@ -10,6 +10,7 @@ use App\Models\Server;
use App\Rules\ValidServerIp;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -352,6 +353,45 @@ class Show extends Component
}
}
public function toggleManagement(ServerTransferClaimer $claimer): void
{
abort_unless(isDev(), 404);
$this->authorize('update', $this->server);
if ($this->server->isLocalhost()) {
$this->dispatch('error', 'The Coolify host cannot be transferred.');
return;
}
if ($this->server->isTransferredAway() && $this->server->team->serverOverflow()) {
$this->dispatch('error', 'Your team is over its server limit. Upgrade your subscription or remove a server first.');
return;
}
if ($this->server->isTransferredAway()) {
$claimer->claim($this->server, writeRemote: false, rebindSentinel: true);
$event = 'ui.server.management_enabled';
$message = 'This Coolify instance now manages the server.';
} else {
$claimer->markTransferred($this->server);
$event = 'ui.server.management_disabled';
$message = 'Server automations are disabled on this Coolify instance.';
}
$this->server->refresh();
$this->syncData();
auditLog($event, [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
$this->dispatch('success', $message);
}
public function checkLocalhostConnection()
{
try {
@@ -55,10 +55,16 @@ class ServerTransferClaimer
$server->settings->sentinel_custom_url = $instanceUrl;
$server->settings->ensureValidSentinelToken();
$server->settings->is_sentinel_enabled = true;
$server->settings->save();
$sentinelRebound = true;
}
if ($server->settings) {
if (! $server->team->serverOverflow()) {
$server->settings->force_disabled = false;
}
$server->settings->save();
}
$metadata = $server->server_metadata ?? [];
$metadata['transfer'] = array_merge((array) data_get($metadata, 'transfer', []), [
'status' => 'claimed',
@@ -154,6 +154,52 @@
@endif
</x-application.settings-section>
@if (isDev())
<x-application.settings-section id="server-management-ownership-section"
title="Management ownership (Dev)"
helper="Choose whether this Coolify instance can control this server.">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="text-sm font-medium text-neutral-950 dark:text-fg">
Manage this server from this instance
</div>
<p class="mt-1 max-w-2xl text-xs leading-5 text-neutral-500 dark:text-fg-dim">
@if ($server->isTransferredAway())
Automations are disabled here. Existing workloads continue to run on the server.
@else
This instance runs deployments, webhooks, scheduled tasks, backups, proxy updates,
and monitoring for this server.
@endif
</p>
</div>
<button type="button" role="switch"
aria-checked="{{ $server->isTransferredAway() ? 'false' : 'true' }}"
wire:click="toggleManagement"
wire:loading.attr="disabled"
wire:target="toggleManagement"
wire:confirm="{{ $server->isTransferredAway()
? 'Take ownership of this server? Confirm that no other Coolify instance manages it.'
: 'Stop managing this server from this instance? Deployments, webhooks, scheduled tasks, backups, proxy updates, and monitoring will be disabled here.' }}"
@cannot('update', $server) disabled @endcannot
class="relative inline-flex h-7 w-12 shrink-0 items-center rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-50 {{ $server->isTransferredAway()
? 'border-neutral-300 bg-neutral-200 dark:border-white/[0.14] dark:bg-white/[0.08]'
: 'border-coollabs bg-coollabs dark:border-warning dark:bg-warning' }}">
<span class="sr-only">Manage this server from this instance</span>
<span aria-hidden="true"
class="inline-block size-5 rounded-full bg-white shadow-sm transition-transform dark:bg-black {{ $server->isTransferredAway() ? 'translate-x-1' : 'translate-x-6' }}"></span>
</button>
</div>
@if ($server->isTransferredAway())
<x-callout type="warning" title="Management is disabled on this instance" class="mt-4">
Before you enable management, disable this server on every other Coolify instance. Two
active instances can cause conflicting deployments, proxy changes, backups, and restarts.
</x-callout>
@endif
</x-application.settings-section>
@endif
<x-application.settings-section id="server-connection-section" title="Connection"
helper="Configure how Coolify identifies, reaches, and validates this server.">
<x-slot:actions>
@@ -0,0 +1,163 @@
<?php
use App\Livewire\Server\Show;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\ServerTransferClaimer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.env' => 'local']);
InstanceSettings::forceCreate([
'id' => 0,
'fqdn' => 'https://coolify-a.test',
]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->team->members()->attach($this->owner, ['role' => 'owner']);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
});
test('owner can stop managing a server from this instance', function () {
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->assertDispatched('success');
$this->server->refresh();
expect($this->server->isTransferredAway())->toBeTrue()
->and((bool) $this->server->settings->force_disabled)->toBeTrue()
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
});
test('owner can take management of a transferred server', function () {
$this->server->server_metadata = [
'transfer' => ['status' => 'transferred'],
];
$this->server->save();
$this->server->settings->forceFill([
'force_disabled' => true,
'is_sentinel_enabled' => false,
])->save();
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->assertDispatched('success');
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
->and((bool) $this->server->settings->force_disabled)->toBeFalse()
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeTrue()
->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test');
});
function markServerTransferredAway(Server $server): void
{
$server->server_metadata = [
'transfer' => ['status' => 'transferred'],
];
$server->save();
$server->settings->forceFill([
'force_disabled' => true,
'is_sentinel_enabled' => false,
])->save();
}
test('cloud team over its server limit cannot take management of a transferred server', function () {
config()->set('constants.coolify.self_hosted', false);
$this->team->forceFill(['custom_server_limit' => 1])->save();
Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
markServerTransferredAway($this->server);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->assertDispatched('error')
->assertNotDispatched('success');
$this->server->refresh();
expect($this->server->isTransferredAway())->toBeTrue()
->and((bool) $this->server->settings->force_disabled)->toBeTrue();
});
test('cloud team within its server limit can take management of a transferred server', function () {
config()->set('constants.coolify.self_hosted', false);
$this->team->forceFill(['custom_server_limit' => 1])->save();
markServerTransferredAway($this->server);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->assertDispatched('success');
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
->and((bool) $this->server->settings->force_disabled)->toBeFalse();
});
test('claiming a server keeps it force disabled when the cloud team is over its server limit', function () {
config()->set('constants.coolify.self_hosted', false);
$this->team->forceFill(['custom_server_limit' => 1])->save();
Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
markServerTransferredAway($this->server);
app(ServerTransferClaimer::class)->claim($this->server, writeRemote: false);
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
->and((bool) $this->server->settings->force_disabled)->toBeTrue();
});
test('team members cannot change server management ownership', function () {
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->assertForbidden();
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
});
test('server overview explains the management ownership switch', function () {
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Manage this server from this instance')
->assertSee('This instance runs deployments, webhooks, scheduled tasks, backups, proxy updates,')
->assertSee('and monitoring for this server.');
});
test('management ownership is not available outside development', function () {
config(['app.env' => 'production']);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertDontSee('Manage this server from this instance')
->call('toggleManagement')
->assertNotFound();
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
});