Files
coolify/tests/Unit/ServerTransfer/ServerTransferClaimerTest.php
Andras BacsaiandClaude Opus 5.5 cf9f254507 fix(server-transfer): close security holes in import, claim, and archives
Import:
- Check every uuid in a bundle against a strict id format before any
  write, and quote the SSH mux socket and key paths in local shell
  commands. A bundle uuid could run commands in the Coolify container.
- Validate bundle fqdns, git source URLs, paths, networks, cron
  expressions, and database ports; cap the number of items.
- Do not confirm that another team has a uuid.
- Refuse a project icon path with a uuid that is not a plain id.

Claim, finish, and migrate:
- Pass checklist step keys to wire:click with Js::from (stored XSS).
- Link only to https target server URLs.
- Validate target_instance_url in /transfer/complete; never prefill an
  unverified URL for a transfer back; scope the transfer-back token to
  write+deploy for 1 hour.
- Release on an owner marker only when it is newer than the own claim.
- Do not touch apps that only use the server as an additional
  destination when an archive is released or deleted.
- Give Sentinel a new token on claim.
- Add a checklist step to remove the previous instance's SSH key.
- Block a transfer while backups or scheduled tasks run.
- Authorize refreshMigration and PrivateKey checkConnection, clear
  tokens from Livewire state, hide migration log/error from members.

Archives:
- Refuse ip, user, port, private key, and Cloudflare tunnel changes.
- Throttle Enable management and require a free server slot for it and
  for a transfer back.
- Skip cloud provider calls.
- Re-read force_disabled before each SSH command, so running jobs stop.
- Probe the import check target at the resolved address only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:02:32 +02:00

359 lines
18 KiB
PHP

<?php
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\DeregisterGithubRunnerJob;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Models\Application;
use App\Models\DnsProviderZone;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Services\ServerTransfer\ServerTransferClaimer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
// A claim writes the owner marker on the host over SSH.
Process::fake();
Storage::fake('ssh-keys');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
$this->team = Team::factory()->create();
$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,
'ip' => '10.77.0.10',
'name' => 'claim-me',
'server_metadata' => [
'transfer' => [
'status' => 'imported',
'export_id' => 'export-xyz',
],
],
]);
});
test('markTransferred writes force_disabled and transfer status together', function () {
$result = app(ServerTransferClaimer::class)->markTransferred(
$this->server,
exportId: 'export-xyz',
targetInstanceUrl: 'https://coolify-b.test',
);
expect($result['server_uuid'])->toBe($this->server->uuid);
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('transferred')
->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz')
->and(data_get($this->server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test')
->and((bool) $this->server->settings->force_disabled)->toBeTrue()
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
});
test('claim persists ownership metadata and always rebinds Sentinel', function () {
$result = app(ServerTransferClaimer::class)->claim($this->server);
expect($result['server_uuid'])->toBe($this->server->uuid)
->and($result)->not->toHaveKey('claim_written')
->and(data_get($result, 'claim.instance_url'))->toBe('https://coolify-a.test');
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz')
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeTrue()
->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test');
});
test('claim writes the owner marker with its claim id on the host', function () {
$result = app(ServerTransferClaimer::class)->claim($this->server);
$claimId = data_get($result, 'claim.claim_id');
expect($claimId)->toBeString()->not->toBeEmpty()
->and(data_get($this->server->fresh()->server_metadata, 'transfer.claim.claim_id'))->toBe($claimId);
Process::assertRan(function ($process) use ($claimId) {
$marker = json_decode((string) $process->input, true);
return str_contains($process->command, ServerTransferClaimer::OWNER_MARKER_PATH)
&& data_get($marker, 'claim_id') === $claimId
&& data_get($marker, 'instance_url') === 'https://coolify-a.test';
});
});
test('claim changes nothing when the owner marker cannot be written', function () {
// The new Sentinel token restarts Sentinel; this test is about the marker.
Queue::fake();
Process::fake(['*' => Process::result(errorOutput: 'Permission denied', exitCode: 1)]);
$sentinelUrlBefore = $this->server->settings->sentinel_custom_url;
expect(fn () => app(ServerTransferClaimer::class)->claim($this->server))->toThrow(RuntimeException::class);
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('imported')
->and(data_get($this->server->server_metadata, 'transfer.claim'))->toBeNull()
->and($this->server->settings->sentinel_custom_url)->toBe($sentinelUrlBefore);
});
test('a server is released when its owner marker names a newer claim of another instance', function () {
app(ServerTransferClaimer::class)->claim($this->server);
$released = app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server->fresh(), json_encode([
'claim_id' => 'claim-of-instance-b',
'instance_url' => 'https://8.8.8.8',
'claimed_at' => now()->addMinute()->toIso8601String(),
]));
$this->server->refresh();
expect($released)->toBeTrue()
->and($this->server->isTransferredAway())->toBeTrue()
->and(data_get($this->server->server_metadata, 'transfer.target_instance_url'))->toBe('https://8.8.8.8')
->and(data_get($this->server->server_metadata, 'transfer.claimed_by_marker'))->toBeTrue()
->and((bool) $this->server->settings->force_disabled)->toBeTrue()
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
});
test('a server is not released when the owner marker is its own claim or missing', function (?string $marker) {
$claimId = data_get(app(ServerTransferClaimer::class)->claim($this->server), 'claim.claim_id');
$marker = $marker === 'own' ? json_encode(['claim_id' => $claimId, 'instance_url' => 'https://coolify-a.test']) : $marker;
$released = app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server->fresh(), $marker);
expect($released)->toBeFalse()
->and($this->server->fresh()->isTransferredAway())->toBeFalse()
->and((bool) $this->server->fresh()->settings->force_disabled)->toBeFalse();
})->with([
'own claim' => 'own',
'no marker' => '',
'unreadable marker' => 'not json',
'marker without claim id' => '{"instance_url":"https://coolify-b.test"}',
]);
test('a server that this instance never claimed ignores the owner marker', function () {
$released = app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server, json_encode([
'claim_id' => 'left-over-claim-of-a-deleted-instance',
'instance_url' => 'https://coolify-b.test',
]));
expect($released)->toBeFalse()
->and($this->server->fresh()->isTransferredAway())->toBeFalse();
});
test('markTransferred keeps the DNS records of the server resources as not owned, without provider calls', function () {
Http::fake();
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create(['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]);
$otherApplication = Application::factory()->create();
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']);
$zone = DnsProviderZone::factory()->create(['integration_token_id' => $token->id]);
$onlyThisServer = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id]);
$onlyThisServer->addReference($application);
$shared = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id]);
$shared->addReference($application);
$shared->addReference($otherApplication);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
Http::assertNothingSent();
expect($onlyThisServer->fresh()->owned)->toBeFalse()
->and($onlyThisServer->fresh()->references()->pluck('resource_id')->all())->toBe([$application->id])
->and(data_get($this->server->fresh()->server_metadata, 'transfer.parked_dns_record_ids'))->toBe([$onlyThisServer->id])
->and($shared->fresh()->owned)->toBeTrue()
->and($shared->fresh()->references()->pluck('resource_id')->all())->toBe([$otherApplication->id]);
});
test('take back owns the parked DNS records again and starts the finish list', function (string $remoteComment, bool $reclaimed) {
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create(['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]);
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']);
$zone = DnsProviderZone::factory()->create(['integration_token_id' => $token->id]);
$record = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id, 'name' => 'app.example.test', 'type' => 'A', 'content' => '10.0.0.1']);
$record->addReference($application);
$claimer = app(ServerTransferClaimer::class);
$claimer->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$claimer->claim($this->server->fresh());
$remote = ['type' => 'A', 'name' => 'app.example.test', 'content' => '10.0.0.1', 'comment' => str_replace('{uuid}', $record->uuid, $remoteComment)];
Http::fake([
'api.cloudflare.com/*' => fn ($request) => $request->method() === 'GET'
? Http::response(['success' => true, 'result' => $remote])
: Http::response(['success' => true, 'result' => [...$remote, 'comment' => $record->ownershipComment()]]),
]);
$items = $claimer->restoreAfterTakeBack($this->server->fresh());
expect($record->fresh()->owned)->toBe($reclaimed)
->and(data_get($this->server->fresh()->server_metadata, 'transfer.parked_dns_record_ids'))->toBeNull()
->and(array_column($items, 'key'))->toContain("dns_record:{$record->uuid}", "manual_webhook:{$application->uuid}")
->and(data_get($this->server->fresh()->server_metadata, 'transfer.finish.items'))->toBe($items);
if ($reclaimed) {
Http::assertSent(fn ($request) => $request->method() === 'PATCH' && $request['comment'] === $record->ownershipComment());
} else {
Http::assertNotSent(fn ($request) => $request->method() === 'PATCH');
}
})->with([
'the other instance adopted the record' => ['managed-by: coolify other-instance/{uuid}', true],
'someone changed the record at the provider' => ['set by hand', false],
]);
function transferRunnerSetup(Server $server): array
{
$githubApp = GithubApp::create([
'name' => 'runner-app',
'organization' => 'acme',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 5151,
'installation_id' => 5252,
'webhook_secret' => 'runner-secret',
'team_id' => $server->team_id,
'is_system_wide' => false,
]);
$config = GithubRunnerConfig::create(['server_id' => $server->id, 'github_app_id' => $githubApp->id, 'labels' => ['coolify'], 'is_enabled' => true]);
return [$githubApp, $config];
}
test('markTransferred disables the runner config and stops tracking the runners without removing them at GitHub', function () {
Queue::fake();
[$githubApp, $config] = transferRunnerSetup($this->server);
$execution = fn (GithubRunnerStatus $status, array $attributes = []) => GithubRunnerExecution::create([
'github_app_id' => $githubApp->id,
'github_runner_config_id' => $config->id,
'server_id' => $this->server->id,
'trigger_workflow_job_id' => fake()->unique()->numberBetween(1000, 9999),
'status' => $status,
'runner_id' => fake()->unique()->numberBetween(1, 999),
...$attributes,
]);
$active = collect([
$execution(GithubRunnerStatus::Queued, ['runner_id' => null]),
$execution(GithubRunnerStatus::Provisioning),
$execution(GithubRunnerStatus::Idle),
$execution(GithubRunnerStatus::Running),
]);
$completed = $execution(GithubRunnerStatus::Completed, ['completed_at' => now()->subDay(), 'error_message' => null]);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
expect($config->fresh()->is_enabled)->toBeFalse()
->and($completed->fresh()->only(['status', 'error_message']))->toBe(['status' => GithubRunnerStatus::Completed, 'error_message' => null]);
foreach ($active as $row) {
$row->refresh();
expect($row->status)->toBe(GithubRunnerStatus::Cancelled)
->and($row->completed_at)->not->toBeNull()
->and($row->error_message)->toBe('The server was transferred to another Coolify instance. This instance stopped managing its runners.');
}
// Deleting the archive later must not pull the runners that now belong to the target.
$this->server->fresh()->forceDelete();
Queue::assertNotPushed(DeregisterGithubRunnerJob::class);
Queue::assertNotPushed(CleanupGithubRunnerJob::class);
});
test('a GitHub webhook does not queue a runner for a transferred server', function () {
Queue::fake();
[$githubApp] = transferRunnerSetup($this->server);
$send = function () use ($githubApp) {
$body = json_encode([
'action' => 'queued',
'workflow_job' => ['id' => fake()->unique()->numberBetween(1000, 9999), 'labels' => ['self-hosted', 'coolify'], 'name' => 'test'],
'repository' => ['id' => 99, 'full_name' => 'acme/api'],
'installation' => ['id' => $githubApp->installation_id],
], JSON_THROW_ON_ERROR);
return $this->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'workflow_job',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => (string) $githubApp->app_id,
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'runner-secret'),
'CONTENT_TYPE' => 'application/json',
], $body);
};
$send()->assertSee('Runner queued.');
GithubRunnerExecution::query()->delete();
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$send()->assertSee('No runner configuration matches the job labels.');
expect(GithubRunnerExecution::count())->toBe(0);
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
});
test('an owner marker that is not newer than the own claim, or has no http(s) URL, does not release the server', function (array $marker) {
app(ServerTransferClaimer::class)->claim($this->server);
$marker = ['claim_id' => 'forged-claim', 'instance_url' => 'https://8.8.8.8', 'claimed_at' => now()->addMinute()->toIso8601String(), ...$marker];
$released = app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server->fresh(), json_encode($marker));
$this->server->refresh();
expect($released)->toBeFalse()
->and($this->server->isTransferredAway())->toBeFalse()
->and((bool) $this->server->settings->force_disabled)->toBeFalse()
->and(data_get($this->server->server_metadata, 'transfer.owner_marker_conflict.claim_id'))->toBe('forged-claim');
})->with([
'older claim' => [['claimed_at' => now()->subDay()->toIso8601String()]],
'no claim time' => [['claimed_at' => null]],
'javascript URL' => [['instance_url' => 'javascript:alert(1)']],
'no URL' => [['instance_url' => null]],
]);
test('a newer owner marker of a self-hosted target with a plain http URL releases the server, and the URL stays untrusted', function () {
app(ServerTransferClaimer::class)->claim($this->server);
$marker = ['claim_id' => 'target-claim', 'instance_url' => 'http://10.0.0.5:8000', 'claimed_at' => now()->addMinute()->toIso8601String()];
$released = app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server->fresh(), json_encode($marker));
$this->server->refresh();
expect($released)->toBeTrue()
->and($this->server->isTransferredAway())->toBeTrue()
->and(data_get($this->server->server_metadata, 'transfer.target_instance_url'))->toBe('http://10.0.0.5:8000')
->and(data_get($this->server->server_metadata, 'transfer.claimed_by_marker'))->toBeTrue();
});
test('claim gives Sentinel a new token', function () {
$tokenBefore = $this->server->settings->sentinel_token;
app(ServerTransferClaimer::class)->claim($this->server);
expect($this->server->fresh()->settings->sentinel_token)->not->toBe($tokenBefore)->not->toBeEmpty();
});
test('applications that use the server only as an additional destination keep their DNS records and survive the archive', function () {
Http::fake();
Queue::fake();
$otherServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => '10.77.0.11']);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create(['destination_id' => $otherDestination->id, 'destination_type' => $otherDestination->getMorphClass()]);
$application->additional_servers()->attach($this->server->id, ['standalone_docker_id' => $destination->id]);
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']);
$record = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => DnsProviderZone::factory()->create(['integration_token_id' => $token->id])->id]);
$record->addReference($application);
$claimer = app(ServerTransferClaimer::class);
$claimer->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
expect($record->fresh()->owned)->toBeTrue()
->and(data_get($this->server->fresh()->server_metadata, 'transfer.parked_dns_record_ids'))->toBe([]);
$claimer->deleteArchive($this->server->fresh());
expect(Application::find($application->id))->not->toBeNull()
->and($application->additional_servers()->count())->toBe(0)
->and($record->fresh()->owned)->toBeTrue()
->and(Server::withTrashed()->find($this->server->id))->toBeNull();
});