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>
This commit is contained in:
Andras Bacsai
2026-10-23 21:02:32 +02:00
co-authored by Claude Opus 5.5
parent 95a3a22fdf
commit cf9f254507
33 changed files with 1480 additions and 92 deletions
+7 -6
View File
@@ -69,7 +69,8 @@ class SshMultiplexingHelper
*/
private static function assertNotDisabled(Server $server): void
{
if ($server->settings->force_disabled) {
// Read the saved flag too: a job that loaded the server before a transfer finished must stop.
if ($server->settings->force_disabled || ($server->exists && $server->settings()->value('force_disabled'))) {
throw new \RuntimeException('Server is disabled.');
}
}
@@ -84,7 +85,7 @@ class SshMultiplexingHelper
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
$establishCommand = 'ssh -fN -o ControlMaster=auto -o ControlPath='.escapeshellarg($muxSocket)." -o ControlPersist={$muxPersistTime} ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
@@ -301,7 +302,7 @@ class SshMultiplexingHelper
$muxSocket = $sshConfig['muxFilename'];
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=".escapeshellarg($muxSocket).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
@@ -366,7 +367,7 @@ class SshMultiplexingHelper
private static function muxControlCommand(Server $server, string $operation): string
{
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
$command = "ssh -O {$operation} -o ControlPath=".escapeshellarg(self::muxSocket($server)).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
@@ -377,7 +378,7 @@ class SshMultiplexingHelper
private static function multiplexingOptions(Server $server): string
{
return '-o ControlMaster=auto '
.'-o ControlPath='.self::muxSocket($server).' '
.'-o ControlPath='.escapeshellarg(self::muxSocket($server)).' '
.'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
}
@@ -433,7 +434,7 @@ class SshMultiplexingHelper
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{
$options = "-i {$sshKeyLocation} "
$options = '-i '.escapeshellarg($sshKeyLocation).' '
.'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
.'-o PasswordAuthentication=no '
."-o ConnectTimeout=$connectionTimeout "
@@ -138,7 +138,7 @@ class ServerTransferController extends Controller
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function status(string $uuid): JsonResponse
public function status(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -152,12 +152,18 @@ class ServerTransferController extends Controller
$this->authorize('view', $server);
// The log and the error of a transfer can name hosts and credentials problems, so members see only the state.
$migration = data_get($server->server_metadata, 'migration');
if (is_array($migration) && ! $request->user()->can('update', $server)) {
$migration = collect($migration)->except(['log', 'error'])->all();
}
return response()->json([
'server_uuid' => $server->uuid,
'transfer' => collect((array) data_get($server->server_metadata, 'transfer', []))
->only(['status', 'export_id', 'imported_at', 'claimed_at', 'transferred_at', 'target_instance_url'])
->all(),
'migration' => data_get($server->server_metadata, 'migration'),
'migration' => $migration,
]);
}
@@ -222,10 +228,16 @@ class ServerTransferController extends Controller
// server would leave an imported server here that this instance cannot manage.
// On Cloud the probe is rate limited and cannot reach private, loopback, or link-local addresses,
// so users cannot scan ports of Cloud's own network.
// A host name is resolved once and the probe connects to that address, so DNS cannot change it in between.
Server::throttleValidation($teamId, $ip);
$probeHost = filter_var(Server::normalizeIp($ip), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.Server::normalizeIp($ip).']' : $ip;
$probeAllowed = ! isCloud() || Validator::make(['ip' => "http://{$probeHost}:{$port}"], ['ip' => [new SafeWebhookUrl]])->passes();
$connection = $probeAllowed ? @fsockopen($ip, $port, timeout: 5) : false;
$probeIp = Server::normalizeIp($ip);
if (isCloud() && ! filter_var($probeIp, FILTER_VALIDATE_IP)) {
$probeIp = gethostbyname($probeIp);
}
$probeHost = filter_var($probeIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? "[{$probeIp}]" : $probeIp;
$probeAllowed = ! isCloud() || (filter_var($probeIp, FILTER_VALIDATE_IP)
&& Validator::make(['ip' => "http://{$probeHost}:{$port}"], ['ip' => [new SafeWebhookUrl]])->passes());
$connection = $probeAllowed ? @fsockopen($probeHost, $port, timeout: 5) : false;
if ($connection !== false) {
fclose($connection);
}
@@ -245,7 +257,7 @@ class ServerTransferController extends Controller
'ok' => $connection !== false,
'message' => match (true) {
$connection !== false => null,
! $probeAllowed => "This instance cannot connect to a private, loopback, or link-local address ({$ip}). Use the public IP address of the server.",
! $probeAllowed => "This instance cannot connect to {$ip}: it is a private, loopback, or link-local address, or it does not resolve to a public IPv4 address. Use the public IP address of the server.",
default => "This instance cannot reach the server at {$ip}:{$port}. Allow SSH from this instance to the server, then transfer again.",
},
],
@@ -399,11 +411,23 @@ class ServerTransferController extends Controller
], 422);
}
// The UI prefills this URL for a transfer back, which sends a team token to it.
$targetInstanceUrl = $request->input('target_instance_url');
if (filled($targetInstanceUrl)) {
try {
$targetInstanceUrl = ServerTransferMigrator::validateInstanceUrl($targetInstanceUrl);
} catch (ValidationException $e) {
return response()->json(['message' => 'Validation failed.', 'errors' => ['target_instance_url' => $e->validator->errors()->all()]], 422);
} catch (Throwable $e) {
return response()->json(['message' => 'Validation failed.', 'errors' => ['target_instance_url' => [$e->getMessage()]]], 422);
}
}
try {
$result = $this->claimer->markTransferred(
$server,
exportId: $request->input('export_id'),
targetInstanceUrl: $request->input('target_instance_url'),
targetInstanceUrl: filled($targetInstanceUrl) ? $targetInstanceUrl : null,
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
@@ -53,6 +53,27 @@ class ServersController extends Controller
->put('is_build_server', $settings->effectiveServerRole() === ServerRole::BUILD);
}
/**
* Another instance manages a transferred server. Changing its host or credentials would let
* "Enable management" connect to a different host, so these fields stay as they are.
*
* @return array<string, array<int, string>>
*/
private function transferredServerConnectionChanges(Request $request, ModelsServer $server): array
{
$changed = [
'ip' => $request->filled('ip') && ModelsServer::normalizeIp($request->input('ip')) !== ModelsServer::normalizeIp($server->getRawOriginal('ip')),
'user' => $request->filled('user') && (string) $request->input('user') !== (string) $server->user,
'port' => $request->filled('port') && (int) $request->input('port') !== (int) $server->port,
'private_key_uuid' => $request->filled('private_key_uuid') && $request->input('private_key_uuid') !== $server->privateKey?->uuid,
];
return collect($changed)
->filter()
->map(fn () => ['This field cannot be changed on a transferred server.'])
->all();
}
/**
* Rejects requests where the deprecated is_build_server flag disagrees with server_role.
*/
@@ -775,6 +796,12 @@ class ServersController extends Controller
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
if ($server->isTransferredAway() && ($connectionErrors = $this->transferredServerConnectionChanges($request, $server))) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $connectionErrors,
], 422);
}
if ($request->instant_validate) {
ModelsServer::throttleValidation($teamId, $request->ip ?? $server->ip);
}
@@ -227,7 +227,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null";
$checkCommand = 'ssh -O check -o ControlPath='.escapeshellarg($muxSocket).' '.escapeshellarg($server->user).'@'.escapeshellarg($server->ip).' 2>/dev/null';
$checkProcess = Process::run($checkCommand);
if ($checkProcess->exitCode() !== 0) {
@@ -272,7 +272,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$checkProcess = Process::run("ssh -O check -o ControlPath={$muxSocket} localhost");
$checkProcess = Process::run('ssh -O check -o ControlPath='.escapeshellarg($muxSocket).' localhost');
$pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches)
? $matches[1]
: null;
@@ -281,7 +281,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
SshMultiplexingHelper::markMuxProcessAsRetiring($pid, $muxSocket);
}
$closeCommand = "ssh -O stop -o ControlPath={$muxSocket} localhost 2>/dev/null";
$closeCommand = 'ssh -O stop -o ControlPath='.escapeshellarg($muxSocket).' localhost 2>/dev/null';
$stopProcess = Process::run($closeCommand);
if ($pid !== null && ! $stopProcess->successful()) {
SshMultiplexingHelper::unmarkMuxProcessAsRetiring($pid, $muxSocket);
@@ -19,6 +19,9 @@ class CloudflareTunnelChangedNotification
$ssh_domain = data_get($event, 'data.ssh_domain');
$this->server = Server::where('id', $server_id)->firstOrFail();
if ($this->server->isTransferredAway()) {
return;
}
// Check if cloudflare tunnel is running (container is healthy) - try 3 times with 5 second intervals
$cloudflareHealthy = false;
+2
View File
@@ -115,6 +115,7 @@ class CloudflareTunnel extends Component
{
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$this->isCloudflareTunnelsEnabled = true;
$this->server->settings->is_cloudflare_tunnel = true;
$this->server->settings->save();
@@ -130,6 +131,7 @@ class CloudflareTunnel extends Component
{
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$this->ssh_domain = $this->normalizeSshDomain($this->ssh_domain);
$activity = ConfigureCloudflared::run($this->server, $this->cloudflare_token, $this->ssh_domain);
auditLog('ui.server.cloudflare_tunnel.configuration_started', $this->auditContext([
+7 -2
View File
@@ -70,11 +70,16 @@ class Delete extends Component
return;
}
$resources = $this->server->definedResources();
if ($isArchive) {
app(ServerTransferClaimer::class)->forgetDnsRecords($this->server);
$claimer = app(ServerTransferClaimer::class);
$claimer->forgetDnsRecords($this->server);
// Applications that use the server only as an additional destination stay; they lose this server.
$claimer->detachAdditionalApplications($this->server);
$resources = $claimer->archiveResources($this->server);
}
if ($this->force_delete_resources) {
foreach ($this->server->definedResources() as $resource) {
foreach ($resources as $resource) {
DeleteResourceJob::dispatch($resource, deleteFromCoolifyOnly: $isArchive);
}
}
+6
View File
@@ -46,6 +46,11 @@ class Show extends Component
return;
}
$this->authorize('update', $this->server);
if ($this->server->isTransferredAway()) {
$this->dispatch('error', 'The private key of a transferred server cannot be changed.');
return;
}
try {
Server::throttleValidation($this->server->team_id, $this->server->ip);
} catch (ThrottleRequestsException $e) {
@@ -101,6 +106,7 @@ class Show extends Component
public function checkConnection()
{
try {
$this->authorize('update', $this->server);
Server::throttleValidation($this->server->team_id, $this->server->ip);
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if ($uptime) {
+42 -1
View File
@@ -7,6 +7,7 @@ use App\Enums\ServerRole;
use App\Events\ServerReachabilityChanged;
use App\Models\CloudProviderToken;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidServerIp;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
@@ -251,6 +252,12 @@ class Show extends Component
if ($toModel) {
$this->validate();
$this->ip = Server::normalizeIp($this->ip);
if ($this->server->isTransferredAway() && $this->connectionFieldsChanged()) {
$this->ip = $this->server->ip;
$this->user = $this->server->user;
$this->port = $this->server->port;
throw new \Exception('The IP address, SSH user, and SSH port of a transferred server cannot be changed.');
}
// Check only a changed IP, so that an old duplicate does not block saving other fields.
$ipChanged = $this->ip !== Server::normalizeIp($this->server->getRawOriginal('ip'));
$foundServer = $ipChanged ? Server::where('ip', $this->ip)
@@ -322,6 +329,13 @@ class Show extends Component
}
}
private function connectionFieldsChanged(): bool
{
return $this->ip !== Server::normalizeIp($this->server->getRawOriginal('ip'))
|| (string) $this->user !== (string) $this->server->user
|| (int) $this->port !== (int) $this->server->port;
}
/**
* Server columns are audited by the model. Settings live on ServerSetting, so record their names here.
*
@@ -406,13 +420,16 @@ class Show extends Component
return;
}
if ($this->server->team->serverOverflow()) {
// The archive does not use a server slot, so taking it back needs a free slot.
if (Team::serverLimitReached($this->server->team)) {
$this->dispatch('error', 'Your team is over its server limit. Upgrade your subscription or remove a server first.');
return;
}
try {
// The claim connects to the host over SSH, so it uses the same Cloud limit as a validation.
Server::throttleValidation($this->server->team_id, $this->server->ip);
$claimer->claim($this->server);
} catch (\Throwable $e) {
handleError($e, $this);
@@ -595,6 +612,9 @@ class Show extends Component
{
try {
$this->authorize('view', $this->server);
if ($this->server->isTransferredAway()) {
return;
}
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
@@ -644,6 +664,9 @@ class Show extends Component
{
try {
$this->authorize('view', $this->server);
if ($this->server->isTransferredAway()) {
return;
}
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');
@@ -666,6 +689,9 @@ class Show extends Component
{
try {
$this->authorize('view', $this->server);
if ($this->server->isTransferredAway()) {
return;
}
if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.');
@@ -688,6 +714,9 @@ class Show extends Component
{
try {
$this->authorize('view', $this->server);
if ($this->server->isTransferredAway()) {
return null;
}
if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.');
@@ -918,6 +947,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
@@ -959,6 +989,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
@@ -990,6 +1021,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
@@ -1046,6 +1078,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId);
if (! $token) {
@@ -1087,6 +1120,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId);
if (! $token) {
@@ -1118,6 +1152,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId);
if (! $token) {
@@ -1177,6 +1212,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
if (! $token) {
@@ -1217,6 +1253,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
if (! $token) {
@@ -1248,6 +1285,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
if (! $token) {
@@ -1307,6 +1345,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
@@ -1348,6 +1387,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
@@ -1379,6 +1419,7 @@ class Show extends Component
try {
$this->authorize('update', $this->server);
$this->server->assertNotTransferredAway();
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
+22 -3
View File
@@ -38,7 +38,12 @@ class Transfer extends Component
// The transfer exports every credential of the server, so only admins and owners may open it.
$this->authorize('update', $this->server);
$this->exportId = data_get($this->server->server_metadata, 'transfer.export_id');
$this->managerUrl = (string) data_get($this->server->server_metadata, 'transfer.target_instance_url', '');
// Transfer back sends a new team token to this URL, so it is prefilled only when this instance chose it.
$managerUrl = data_get($this->server->server_metadata, 'transfer.target_instance_url');
if (is_string($managerUrl) && ! data_get($this->server->server_metadata, 'transfer.claimed_by_marker')
&& ServerTransferMigrator::isValidInstanceUrl($managerUrl)) {
$this->managerUrl = $managerUrl;
}
$this->thisInstanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
} catch (Throwable $e) {
handleError($e, $this);
@@ -77,8 +82,19 @@ class Transfer extends Component
return array_values(array_filter((array) data_get($this->migration, 'checklist', []), 'is_string'));
}
/**
* The link to the server on the target. The target sends it, so only an https link is shown.
*/
public function getTargetServerUrlProperty(): ?string
{
$url = data_get($this->migration, 'target_server_url');
return ServerTransferMigrator::isLinkableUrl($url) ? $url : null;
}
public function refreshMigration(): void
{
$this->authorize('update', $this->server);
$this->server->refresh();
$this->lastWarnings = array_values((array) data_get($this->migration, 'warnings', $this->lastWarnings));
}
@@ -105,12 +121,14 @@ class Transfer extends Component
$this->server->refresh();
$this->lastWarnings = [];
$this->targetToken = '';
$this->dispatch('success', 'All checks passed. The transfer started.');
} catch (Throwable $e) {
// A failed check is saved in the transfer log, so show it.
$this->server->refresh();
handleError($e, $this);
} finally {
// Public properties are sent to the browser with every response.
$this->targetToken = '';
}
}
@@ -127,10 +145,11 @@ class Transfer extends Component
]);
$this->server->refresh();
$this->managerToken = '';
$this->dispatch('success', 'The other instance started the transfer back.');
} catch (Throwable $e) {
handleError($e, $this);
} finally {
$this->managerToken = '';
}
}
+24
View File
@@ -413,6 +413,10 @@ class Server extends BaseModel
*/
public function backfillPlaceholderIp(?string $ip): bool
{
if ($this->isTransferredAway()) {
return false;
}
$ip = blank($ip) ? null : self::normalizeIp($ip);
if (self::isPlaceholderIp($ip)) {
return false;
@@ -457,6 +461,11 @@ class Server extends BaseModel
public function refreshHetznerState(): ?string
{
// Another instance manages the cloud server of an archive; keep the stored status.
if ($this->isTransferredAway()) {
return $this->hetzner_server_status;
}
if (! $this->hetzner_server_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hetzner') {
return $this->hetzner_server_status;
}
@@ -478,6 +487,11 @@ class Server extends BaseModel
public function refreshVultrState(): ?string
{
// Another instance manages the cloud server of an archive; keep the stored status.
if ($this->isTransferredAway()) {
return $this->vultr_instance_status;
}
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
return null;
}
@@ -514,6 +528,11 @@ class Server extends BaseModel
public function refreshDigitalOceanState(): ?string
{
// Another instance manages the cloud server of an archive; keep the stored status.
if ($this->isTransferredAway()) {
return $this->digitalocean_droplet_status;
}
if (! $this->digitalocean_droplet_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'digitalocean') {
return $this->digitalocean_droplet_status;
}
@@ -555,6 +574,11 @@ class Server extends BaseModel
public function refreshHostingerState(): ?string
{
// Another instance manages the cloud server of an archive; keep the stored status.
if ($this->isTransferredAway()) {
return $this->hostinger_virtual_machine_status;
}
if (! $this->hostinger_virtual_machine_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hostinger') {
return $this->hostinger_virtual_machine_status;
}
@@ -22,6 +22,10 @@ class ProjectIconStorageService extends AvatarStorageService
$storageType = $settings->avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local';
$s3StorageId = $storageType === 's3' ? $settings->avatar_s3_storage_id : null;
$disk = $this->disk($storageType, $s3StorageId);
// The uuid is a path segment, so it must not leave the project-icons directory.
if (preg_match('/\A[a-zA-Z0-9_-]+\z/', (string) $project->uuid) !== 1) {
throw new RuntimeException('Unable to store the project icon: the project UUID is not valid.');
}
$path = "project-icons/{$project->uuid}/icon.jpg";
if (! $disk->put($path, $contents)) {
@@ -2,12 +2,60 @@
namespace App\Services\ServerTransfer;
use App\Support\ValidationPatterns;
use Closure;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Throwable;
class ServerTransferBundle
{
public const SCHEMA_VERSION = 1;
/**
* Every `uuid`, `*_uuid`, and `*_uuids` value in a bundle. Coolify ids are random lowercase
* letters and digits (new_public_id) or RFC 4122 UUIDs. The ids go into file paths and local
* shell commands (SSH key file, SSH mux socket, project icon directory).
*/
public const ID_PATTERN = '/\A[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}\z/';
/**
* The most items a bundle can have for each list key, summed over the whole bundle.
*
* @var array<string, int>
*/
public const MAX_ITEMS = [
'projects' => 1000,
'environments' => 5000,
'applications' => 5000,
'databases' => 5000,
'services' => 2000,
'previews' => 10000,
'environment_variables' => 50000,
'environment_variables_preview' => 50000,
'shared_environment_variables' => 20000,
'persistent_storages' => 20000,
'file_storages' => 20000,
'scheduled_tasks' => 10000,
'scheduled_backups' => 10000,
'volume_backups' => 10000,
'tags' => 20000,
'resource_links' => 10000,
'destinations' => 1000,
'private_keys' => 1000,
'github_apps' => 1000,
'gitlab_apps' => 1000,
'gitea_apps' => 1000,
's3_storages' => 1000,
'cloud_provider_tokens' => 1000,
'integration_tokens' => 1000,
'dns_zones' => 1000,
'managed_dns_records' => 10000,
'ssl_certificates' => 10000,
'warnings' => 1000,
'post_transfer_checklist' => 1000,
];
/**
* @param array<string, mixed> $payload
*/
@@ -76,6 +124,8 @@ class ServerTransferBundle
$errors[] = 'Missing projects array.';
}
$errors = array_merge($errors, self::contentErrors($bundle));
return [
'valid' => $errors === [],
'errors' => $errors,
@@ -95,4 +145,272 @@ class ServerTransferBundle
]);
}
}
/**
* Checks the values of a bundle before the import writes anything. A bundle comes from the
* API, so each value gets the validation that the normal API applies to it.
*
* @param array<string, mixed> $bundle
* @return list<string>
*/
public static function contentErrors(array $bundle): array
{
$errors = [];
$counts = [];
self::walk($bundle, '', $errors, $counts);
foreach ($counts as $key => $count) {
if ($count > self::MAX_ITEMS[$key]) {
$errors[] = "The bundle has {$count} {$key}. A bundle can have at most ".self::MAX_ITEMS[$key]." {$key}.";
}
}
// Stop before the field checks: a bundle with a bad id or too many items is not imported.
if ($errors !== []) {
return $errors;
}
foreach (self::items(data_get($bundle, 'destinations')) as $i => $destination) {
self::check($errors, "destinations.{$i}.network", $destination['network'] ?? null, ['nullable', 'string', 'max:255', 'regex:'.ValidationPatterns::DOCKER_NETWORK_PATTERN]);
}
// Only http and https URLs, as for a git source made with the API. The import does not resolve the host:
// the target may not reach the git host yet, and SafeExternalUrl checks the host on every request.
$gitSourceUrl = function (string $attribute, mixed $value, Closure $fail): void {
if (is_string($value) && ! isValidDomainUrl($value)) {
$fail('The :attribute must use the http or https scheme.');
}
};
foreach (['github_apps', 'gitlab_apps', 'gitea_apps'] as $key) {
foreach (self::items(data_get($bundle, $key)) as $i => $source) {
foreach (['html_url', 'api_url'] as $field) {
self::check($errors, "{$key}.{$i}.{$field}", $source[$field] ?? null, ['nullable', 'string', 'url', $gitSourceUrl]);
}
}
}
foreach (self::items(data_get($bundle, 'ssl_certificates')) as $i => $certificate) {
self::checkPath($errors, "ssl_certificates.{$i}.configuration_dir", $certificate['configuration_dir'] ?? null);
self::checkPath($errors, "ssl_certificates.{$i}.mount_path", $certificate['mount_path'] ?? null);
}
foreach (self::items(data_get($bundle, 'volume_backups')) as $i => $backup) {
self::checkCron($errors, "volume_backups.{$i}.frequency", $backup['frequency'] ?? null);
}
foreach (self::items(data_get($bundle, 'projects')) as $p => $project) {
foreach (self::items($project['environments'] ?? null) as $e => $environment) {
$at = "projects.{$p}.environments.{$e}";
foreach (self::items($environment['applications'] ?? null) as $a => $application) {
self::checkDomains($errors, "{$at}.applications.{$a}.fqdn", data_get($application, 'attributes.fqdn'));
self::checkStorages($errors, "{$at}.applications.{$a}", $application);
self::checkScheduledTasks($errors, "{$at}.applications.{$a}", $application);
foreach (self::items($application['previews'] ?? null) as $r => $preview) {
self::checkDomains($errors, "{$at}.applications.{$a}.previews.{$r}.fqdn", $preview['fqdn'] ?? null);
self::checkStorages($errors, "{$at}.applications.{$a}.previews.{$r}", $preview);
}
}
foreach (self::items($environment['databases'] ?? null) as $d => $database) {
self::checkDatabaseAttributes($errors, "{$at}.databases.{$d}", (array) ($database['attributes'] ?? []));
self::checkStorages($errors, "{$at}.databases.{$d}", $database);
self::checkScheduledBackups($errors, "{$at}.databases.{$d}", $database);
}
foreach (self::items($environment['services'] ?? null) as $s => $service) {
self::checkScheduledTasks($errors, "{$at}.services.{$s}", $service);
foreach (self::items($service['applications'] ?? null) as $a => $serviceApplication) {
self::checkDomains($errors, "{$at}.services.{$s}.applications.{$a}.fqdn", data_get($serviceApplication, 'attributes.fqdn', $serviceApplication['fqdn'] ?? null));
self::checkStorages($errors, "{$at}.services.{$s}.applications.{$a}", $serviceApplication);
}
foreach (self::items($service['databases'] ?? null) as $d => $serviceDatabase) {
self::checkStorages($errors, "{$at}.services.{$s}.databases.{$d}", $serviceDatabase);
self::checkScheduledBackups($errors, "{$at}.services.{$s}.databases.{$d}", $serviceDatabase);
}
}
}
}
return $errors;
}
/**
* Checks every id in the bundle and counts the items of every list key in MAX_ITEMS.
*
* @param list<string> $errors
* @param array<string, int> $counts
*/
private static function walk(array $node, string $path, array &$errors, array &$counts): void
{
foreach ($node as $key => $value) {
$at = $path === '' ? (string) $key : "{$path}.{$key}";
if (is_string($key)) {
if ($key === 'uuid' || str_ends_with($key, '_uuid')) {
self::checkId($errors, $at, $value);
} elseif (str_ends_with($key, '_uuids')) {
foreach (is_array($value) ? $value : [$value] as $i => $id) {
self::checkId($errors, "{$at}.{$i}", $id);
}
continue;
}
if (isset(self::MAX_ITEMS[$key]) && is_array($value)) {
$counts[$key] = ($counts[$key] ?? 0) + (array_is_list($value)
? count($value)
: array_sum(array_map(fn ($list) => is_array($list) ? count($list) : 0, $value)));
}
}
if (is_array($value)) {
self::walk($value, $at, $errors, $counts);
}
}
}
/**
* @param list<string> $errors
*/
private static function checkId(array &$errors, string $at, mixed $value): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value) || preg_match(self::ID_PATTERN, $value) !== 1) {
$errors[] = "{$at} is not a valid id. An id may only contain letters, digits, hyphens, and underscores (at most 64 characters).";
}
}
/**
* @return array<int|string, array<string, mixed>>
*/
private static function items(mixed $value): array
{
return is_array($value) ? array_filter($value, 'is_array') : [];
}
/**
* @param list<string> $errors
* @param array<int, mixed> $rules
*/
private static function check(array &$errors, string $at, mixed $value, array $rules): void
{
$validator = Validator::make(['value' => $value], ['value' => $rules], [], ['value' => $at]);
foreach ($validator->errors()->all() as $message) {
$errors[] = $message;
}
}
/**
* @param list<string> $errors
*/
private static function checkDomains(array &$errors, string $at, mixed $value): void
{
foreach (ValidationPatterns::validateApplicationDomains($value) as $message) {
$errors[] = "{$at}: {$message}";
}
}
/**
* @param list<string> $errors
*/
private static function checkPath(array &$errors, string $at, mixed $value): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value)) {
$errors[] = "{$at} must be a string.";
return;
}
try {
validateShellSafePath($value, $at);
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
/**
* @param list<string> $errors
*/
private static function checkCron(array &$errors, string $at, mixed $value): void
{
if ($value === null) {
return;
}
try {
$valid = is_string($value) && validate_cron_expression($value);
} catch (Throwable) {
$valid = false;
}
if (! $valid) {
$errors[] = "{$at} is not a valid cron expression or frequency.";
}
}
/**
* @param list<string> $errors
* @param array<string, mixed> $resource
*/
private static function checkStorages(array &$errors, string $at, array $resource): void
{
foreach (self::items($resource['persistent_storages'] ?? null) as $i => $volume) {
self::checkPath($errors, "{$at}.persistent_storages.{$i}.mount_path", $volume['mount_path'] ?? null);
self::checkPath($errors, "{$at}.persistent_storages.{$i}.host_path", $volume['host_path'] ?? null);
}
foreach (self::items($resource['file_storages'] ?? null) as $i => $storage) {
self::checkPath($errors, "{$at}.file_storages.{$i}.fs_path", $storage['fs_path'] ?? null);
self::checkPath($errors, "{$at}.file_storages.{$i}.mount_path", $storage['mount_path'] ?? null);
}
}
/**
* The same checks as the scheduled task API.
*
* @param list<string> $errors
* @param array<string, mixed> $resource
*/
private static function checkScheduledTasks(array &$errors, string $at, array $resource): void
{
foreach (self::items($resource['scheduled_tasks'] ?? null) as $i => $task) {
self::check($errors, "{$at}.scheduled_tasks.{$i}.command", $task['command'] ?? null, ['required', 'string']);
self::check($errors, "{$at}.scheduled_tasks.{$i}.container", $task['container'] ?? null, ['nullable', ...ValidationPatterns::containerNameRules()]);
self::checkCron($errors, "{$at}.scheduled_tasks.{$i}.frequency", $task['frequency'] ?? '');
}
}
/**
* @param list<string> $errors
* @param array<string, mixed> $database
*/
private static function checkScheduledBackups(array &$errors, string $at, array $database): void
{
foreach (self::items($database['scheduled_backups'] ?? null) as $i => $backup) {
self::checkCron($errors, "{$at}.scheduled_backups.{$i}.frequency", $backup['frequency'] ?? null);
self::checkPath($errors, "{$at}.scheduled_backups.{$i}.databases_to_backup", $backup['databases_to_backup'] ?? null);
}
}
/**
* The port checks of the database API. Names and passwords are not checked: older databases
* have values that the current patterns refuse, and the values stay inside the containers.
*
* @param list<string> $errors
* @param array<string, mixed> $attributes
*/
private static function checkDatabaseAttributes(array &$errors, string $at, array $attributes): void
{
if (array_key_exists('ports_mappings', $attributes)) {
self::check($errors, "{$at}.ports_mappings", $attributes['ports_mappings'], ValidationPatterns::portMappingRules());
}
if (array_key_exists('public_port', $attributes)) {
self::check($errors, "{$at}.public_port", $attributes['public_port'], ['nullable', 'integer', 'between:1024,65535']);
}
}
}
@@ -4,13 +4,16 @@ namespace App\Services\ServerTransfer;
use App\Enums\GithubRunnerStatus;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\GithubRunnerExecution;
use App\Models\ManagedDnsRecord;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Internal\GeneralNotification;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\Dns\ManagedDnsRecordCleanup;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -67,13 +70,16 @@ class ServerTransferClaimer
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
// A disabled server refuses SSH, so the owner marker could not be written.
if ($server->team->serverOverflow()) {
// An archive does not use a server slot, so taking it back needs a free slot.
$overLimit = $server->isTransferredAway() ? Team::serverLimitReached($server->team) : $server->team->serverOverflow();
if ($overLimit) {
throw new RuntimeException('Your team is over its server limit. Upgrade your subscription or remove a server first.');
}
if ($server->settings) {
$server->settings->sentinel_custom_url = $instanceUrl;
$server->settings->ensureValidSentinelToken();
// A new token: the instance that managed the server before keeps a copy of the old one.
$server->settings->generateSentinelToken(save: false);
$server->settings->is_sentinel_enabled = true;
$server->settings->force_disabled = false;
$server->settings->save();
@@ -85,6 +91,7 @@ class ServerTransferClaimer
'claimed_at' => $claim['claimed_at'],
'claim' => $claim,
]);
unset($metadata['transfer']['owner_marker_conflict'], $metadata['transfer']['claimed_by_marker']);
$server->server_metadata = $metadata;
$server->save();
@@ -107,13 +114,13 @@ class ServerTransferClaimer
*
* @return array{server_uuid: string, message: string}
*/
public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null): array
public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null, bool $fromOwnerMarker = false): array
{
if ($server->id === 0) {
throw new RuntimeException('Cannot transfer the Coolify host itself.');
}
$result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl) {
$result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl, $fromOwnerMarker) {
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
if ($server->settings) {
@@ -127,9 +134,11 @@ class ServerTransferClaimer
'status' => 'transferred',
'export_id' => $exportId ?? data_get($metadata, 'transfer.export_id'),
'target_instance_url' => $targetInstanceUrl,
// Anyone who can write files on the host can write the owner marker, so its URL is not trusted.
'claimed_by_marker' => $fromOwnerMarker,
'transferred_at' => now()->toIso8601String(),
]);
unset($metadata['transfer']['finish'], $metadata['transfer']['taken_back_at']);
unset($metadata['transfer']['finish'], $metadata['transfer']['taken_back_at'], $metadata['transfer']['owner_marker_conflict']);
$server->server_metadata = $metadata;
$server->save();
@@ -176,12 +185,49 @@ class ServerTransferClaimer
}
$this->forgetDnsRecords($server);
foreach ($server->definedResources() as $resource) {
$this->detachAdditionalApplications($server);
foreach ($this->archiveResources($server) as $resource) {
(new DeleteResourceJob($resource, deleteFromCoolifyOnly: true))->handle();
}
$server->forceDelete();
}
/**
* The resources that the archive of a transferred server deletes. An application that has the server only as an
* additional destination runs on another server of this instance, so it is not one of them.
*
* @return Collection<int, Model>
*/
public function archiveResources(Server $server): Collection
{
return $this->primaryApplications($server)
->concat($server->databases())
->concat($server->services()->get());
}
/**
* Remove the server from applications that run on it only as an additional destination.
*/
public function detachAdditionalApplications(Server $server): void
{
$primaryIds = $this->primaryApplications($server)->pluck('id');
DB::table('additional_destinations')
->where('server_id', $server->id)
->whereNotIn('application_id', $primaryIds)
->delete();
}
/**
* @return Collection<int, Application>
*/
private function primaryApplications(Server $server): Collection
{
return $server->applications()
->filter(fn (Application $application) => (int) data_get($application, 'destination.server_id') === (int) $server->id)
->unique('id')
->values();
}
/**
* @return Collection<int, Model>
*/
@@ -189,7 +235,7 @@ class ServerTransferClaimer
{
// The server itself owns the DNS record of its Cloudflare tunnel.
return collect([$server])
->concat($server->applications())
->concat($this->primaryApplications($server))
->concat($server->services()->with('applications')->get()->flatMap->applications);
}
@@ -236,10 +282,16 @@ class ServerTransferClaimer
}
/**
* Stop managing a claimed server when its owner marker names a claim of another instance.
* Stop managing a claimed server when its owner marker names a newer claim of another instance.
* A server that was added here without a claim ignores the marker: a marker can be left over on a
* host that an old instance deleted, and the next claim overwrites it.
*
* Anyone who can write files on the host can write the marker, so it is not proof of a claim. A marker that is
* not newer than the claim of this instance, or that names a URL that this instance would not send credentials
* to, does not release the server: this instance keeps managing it and saves the conflict, which the UI shows.
* A forged marker with a newer time and a valid URL still releases the server; it can be taken back with
* Enable management.
*
* @return bool true when this instance released the server
*/
public function releaseIfClaimedElsewhere(Server $server, ?string $markerJson): bool
@@ -255,13 +307,25 @@ class ServerTransferClaimer
}
if ($markerClaimId === data_get($server->server_metadata, 'transfer.claim.claim_id')) {
if (data_get($server->server_metadata, 'transfer.owner_marker_conflict') !== null) {
$this->recordOwnerMarkerConflict($server, null, null);
}
return false;
}
// Any http(s) URL: a default self-hosted target uses http://IP:8000. The URL is not trusted
// (claimed_by_marker), so it is never prefilled for a transfer back.
$owner = data_get($marker, 'instance_url');
$owner = is_string($owner) && $owner !== '' ? $owner : null;
$owner = is_string($owner) && filter_var($owner, FILTER_VALIDATE_URL) && in_array(strtolower((string) parse_url($owner, PHP_URL_SCHEME)), ['http', 'https'], true)
? rtrim(trim($owner), '/') : null;
if ($owner === null || ! $this->isNewerThanOwnClaim($server, data_get($marker, 'claimed_at'))) {
$this->recordOwnerMarkerConflict($server, $markerClaimId, data_get($marker, 'instance_url'));
$this->markTransferred($server, targetInstanceUrl: $owner);
return false;
}
$this->markTransferred($server, targetInstanceUrl: $owner, fromOwnerMarker: true);
Log::warning('Server is claimed by another Coolify instance; management stopped here', [
'server_uuid' => $server->uuid,
@@ -275,6 +339,50 @@ class ServerTransferClaimer
return true;
}
private function isNewerThanOwnClaim(Server $server, mixed $markerClaimedAt): bool
{
$ownClaimedAt = data_get($server->server_metadata, 'transfer.claim.claimed_at');
if (! is_string($markerClaimedAt) || ! is_string($ownClaimedAt)) {
return false;
}
try {
return Carbon::parse($markerClaimedAt)->isAfter(Carbon::parse($ownClaimedAt));
} catch (Throwable) {
return false;
}
}
/**
* Save the untrusted marker of another claim, or remove the saved one when $markerClaimId is null.
*/
private function recordOwnerMarkerConflict(Server $server, ?string $markerClaimId, mixed $instanceUrl): void
{
if ($markerClaimId !== null && data_get($server->server_metadata, 'transfer.owner_marker_conflict.claim_id') === $markerClaimId) {
return;
}
if ($markerClaimId !== null) {
Log::warning('The owner marker on the server names another claim, but it is not trusted; this instance keeps managing the server', [
'server_uuid' => $server->uuid,
'marker_claim_id' => $markerClaimId,
]);
}
DB::transaction(function () use ($server, $markerClaimId, $instanceUrl) {
$locked = Server::query()->lockForUpdate()->findOrFail($server->id);
$metadata = $locked->server_metadata ?? [];
$metadata['transfer']['owner_marker_conflict'] = $markerClaimId === null ? null : [
'claim_id' => $markerClaimId,
'instance_url' => is_string($instanceUrl) ? mb_substr($instanceUrl, 0, 255) : null,
'seen_at' => now()->toIso8601String(),
];
$locked->server_metadata = $metadata;
$locked->save();
$server->server_metadata = $metadata;
});
}
/**
* @param array<string, mixed> $claim
*/
@@ -564,6 +564,8 @@ class ServerTransferExporter
$steps[] = 'After the GitHub App sends webhooks to {target_url}, open this server there, check the runner requirements, and enable GitHub runners. '
.'Enabling them stops runners that another Coolify instance started on this server.';
}
$steps[] = 'This instance keeps a copy of the SSH key of the server. On {target_url}, add a new SSH key to the server and select it there, '
.'then remove the old public key from ~/.ssh/authorized_keys on the server.';
return $steps;
}
@@ -181,7 +181,11 @@ class ServerTransferImporter
bool $preserveUuids = true,
bool $adoptMode = true,
): array {
ServerTransferBundle::assertValid($bundle);
// Checks every id, list size, and imported value before anything is written.
$validation = ServerTransferBundle::validate($bundle);
if (! $validation['valid']) {
throw ValidationException::withMessages(['bundle' => $validation['errors']]);
}
$this->assertValidServerPayload((array) data_get($bundle, 'server', []));
if ($adoptMode && ! $preserveUuids) {
@@ -190,7 +194,6 @@ class ServerTransferImporter
]);
}
$validation = ServerTransferBundle::validate($bundle);
$warnings = $validation['warnings'];
if (is_array(data_get($bundle, 'warnings'))) {
$warnings = array_values(array_unique(array_merge($warnings, $bundle['warnings'])));
@@ -504,8 +507,8 @@ class ServerTransferImporter
{
$team = Team::find($teamId);
$archive = $preserveUuids ? $this->replaceableArchive($serverUuid, $teamId) : null;
// The archive is replaced, so the import does not add a server.
$limitReached = ! $team || ($archive === null && Team::serverLimitReached($team));
// An archive does not use a server slot, so replacing it also needs a free slot.
$limitReached = ! $team || Team::serverLimitReached($team);
$existingServer = $preserveUuids && $archive === null ? Server::withTrashed()->where('uuid', $serverUuid)->first() : null;
$uuidMessage = match (true) {
@@ -592,7 +595,7 @@ class ServerTransferImporter
if ($adoptMode) {
throw ValidationException::withMessages([
'bundle' => ["The {$label} UUID {$uuid} is already used on this instance. Delete that {$label} permanently first, because adopt mode needs the same UUID to keep the running containers."],
'bundle' => ["The {$label} UUID {$uuid} cannot be used on this instance. Adopt mode needs the same UUID to keep the running containers. If you deleted the {$label} on this instance, delete it permanently first."],
]);
}
@@ -2273,7 +2276,7 @@ class ServerTransferImporter
$preview = ApplicationPreview::withTrashed()->where('uuid', $uuid)->first();
if ($preview && ! $this->previewBelongsToTeam($preview, $teamId)) {
throw ValidationException::withMessages([
'previews' => ['An imported preview UUID conflicts with a preview outside the target team.'],
'previews' => ['An imported preview UUID cannot be used on this instance.'],
]);
}
if (! $preview) {
@@ -10,6 +10,9 @@ use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTaskExecution;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\User;
@@ -25,6 +28,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
@@ -48,6 +52,9 @@ class ServerTransferMigrator
*/
public const MIGRATION_TIMEOUT_SECONDS = 1500;
/** Lifetime of the token that a transfer back gives the other instance: the job timeout plus time in the queue. */
public const TRANSFER_BACK_TOKEN_SECONDS = 3600;
/** Longer than the job timeout, so a running state older than this one is stale. */
public const STALE_MIGRATION_SECONDS = 1800;
@@ -304,6 +311,32 @@ class ServerTransferMigrator
'This server was already transferred to another instance.'),
$this->check('no deployment is queued or running on the server', $activeDeployments === 0,
"{$activeDeployments} deployment(s) on this server are queued or running. Wait until they finish or cancel them, then transfer the server."),
...array_map(fn (array $job) => $this->check("no {$job['label']} is running on the server", $job['count'] === 0,
"{$job['count']} {$job['label']}(s) on this server are running. Wait until they finish, then transfer the server."), $this->runningJobs($server)),
];
}
/**
* Backups and scheduled tasks that run on the server. A transfer during a run leaves the run without an owner.
* A run that has been 'running' for a day is left over from a stopped worker, so it does not block.
*
* @return list<array{label: string, count: int}>
*/
private function runningJobs(Server $server): array
{
$onServer = fn ($schedule) => $schedule && (int) $schedule->server()?->id === (int) $server->id;
$running = fn (string $model, string $schedule) => $model::query()
->where('status', 'running')
->where('created_at', '>', now()->subDay())
->with($schedule)
->get()
->filter(fn (Model $execution) => $onServer($execution->{$schedule}))
->count();
return [
['label' => 'database backup', 'count' => $running(ScheduledDatabaseBackupExecution::class, 'scheduledDatabaseBackup')],
['label' => 'volume backup', 'count' => $running(ScheduledVolumeBackupExecution::class, 'scheduledVolumeBackup')],
['label' => 'scheduled task', 'count' => $running(ScheduledTaskExecution::class, 'scheduledTask')],
];
}
@@ -484,7 +517,8 @@ class ServerTransferMigrator
'complete' => $complete,
'warnings' => $warnings,
'checklist' => $checklist,
'target_server_url' => is_string(data_get($importBody, 'server_url')) ? data_get($importBody, 'server_url') : null,
// The target sends this URL, and the UI renders it as a link.
'target_server_url' => self::isLinkableUrl(data_get($importBody, 'server_url')) ? data_get($importBody, 'server_url') : null,
'message' => 'Server migrated to '.$targetUrl.'. Automations disabled on this instance.',
];
}
@@ -523,8 +557,9 @@ class ServerTransferMigrator
throw new RuntimeException('Enter the URL at which the other instance reaches this instance.');
}
// The import checks and the import need write and deploy.
$importToken = $user->createToken("transfer back {$archive->uuid}", ['read', 'write', 'deploy'], now()->addHours(3), $archive->team_id);
// The import checks, the import, and the status probes need only write and deploy. The claim after the
// import deletes the token, and the expiry covers a failed import: the transfer job runs at most 25 minutes.
$importToken = $user->createToken(self::transferBackTokenName($archive->uuid), ['write', 'deploy'], now()->addSeconds(self::TRANSFER_BACK_TOKEN_SECONDS), $archive->team_id);
$migrateUrl = $managerUrl.'/api/v1/servers/'.rawurlencode($archive->uuid).'/migrate';
try {
@@ -555,37 +590,80 @@ class ServerTransferMigrator
});
}
private function normalizeTargetUrl(string $targetUrl): string
/**
* The rules for the URL of another Coolify instance that this instance sends credentials to: https and a public
* address, or a local address in development. Every stored or prefilled instance URL must pass them.
*
* @throws RuntimeException|ValidationException
*/
public static function validateInstanceUrl(string $url): string
{
$targetUrl = rtrim(trim($targetUrl), '/');
if ($targetUrl === '' || ! filter_var($targetUrl, FILTER_VALIDATE_URL)) {
$url = rtrim(trim($url), '/');
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException('A valid target instance URL is required (e.g. https://coolify.example.com).');
}
if ($this->isLocalDevelopmentTarget($targetUrl)) {
if (file_exists('/.dockerenv') || is_file('/run/.containerenv')) {
return (string) preg_replace(
'#^(https?://)(localhost|127\.0\.0\.1)(?=[:/]|$)#i',
'$1host.docker.internal',
$targetUrl,
);
}
return $targetUrl;
if (self::isLocalDevelopmentTarget($url)) {
return $url;
}
if (strtolower((string) parse_url($targetUrl, PHP_URL_SCHEME)) !== 'https') {
if (strtolower((string) parse_url($url, PHP_URL_SCHEME)) !== 'https') {
throw new RuntimeException('The target instance URL must use https, because the transfer sends credentials.');
}
Validator::make(['target_url' => $targetUrl], [
Validator::make(['target_url' => $url], [
'target_url' => ['required', new SafeWebhookUrl],
])->validate();
return $url;
}
public static function isValidInstanceUrl(?string $url): bool
{
try {
self::validateInstanceUrl((string) $url);
return true;
} catch (RuntimeException|ValidationException) {
return false;
}
}
/**
* A URL from another instance that the UI may render as a link: https, or http in development.
*/
public static function isLinkableUrl(mixed $url): bool
{
if (! is_string($url) || ! filter_var($url, FILTER_VALIDATE_URL)) {
return false;
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return $scheme === 'https' || ($scheme === 'http' && isDev());
}
public static function transferBackTokenName(string $serverUuid): string
{
return "transfer back {$serverUuid}";
}
private function normalizeTargetUrl(string $targetUrl): string
{
$targetUrl = self::validateInstanceUrl($targetUrl);
if (self::isLocalDevelopmentTarget($targetUrl) && (file_exists('/.dockerenv') || is_file('/run/.containerenv'))) {
return (string) preg_replace(
'#^(https?://)(localhost|127\.0\.0\.1)(?=[:/]|$)#i',
'$1host.docker.internal',
$targetUrl,
);
}
return $targetUrl;
}
private function isLocalDevelopmentTarget(string $url): bool
private static function isLocalDevelopmentTarget(string $url): bool
{
return isDev()
&& in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)
@@ -801,7 +879,7 @@ class ServerTransferMigrator
private function targetRequest(string $url, string $token, int $timeout): PendingRequest
{
return Http::timeout($timeout)
->withOptions($this->isLocalDevelopmentTarget($url)
->withOptions(self::isLocalDevelopmentTarget($url)
? ['allow_redirects' => false]
: SafeWebhookUrl::httpClientOptions($url))
->acceptJson()
@@ -48,14 +48,14 @@ class TransferFinishChecklist
];
private const GROUP_OF_TYPE = [
'server_connection' => 'server', 'sentinel' => 'server', 's3_storage' => 'server',
'server_connection' => 'server', 'sentinel' => 'server', 'previous_ssh_key' => 'server', 's3_storage' => 'server',
'github_app_webhook' => 'git', 'github_app_urls' => 'git', 'gitlab_webhook' => 'git', 'manual_webhook' => 'git',
'git_source' => 'git', 'github_runners' => 'git',
'dns_record' => 'dns', 'domain' => 'dns',
];
/** Types that the user can only mark as done. */
private const MANUAL_TYPES = ['github_app_urls'];
private const MANUAL_TYPES = ['github_app_urls', 'previous_ssh_key'];
/** Types that Coolify can fix with one click. */
private const FIX_LABELS = [
@@ -145,11 +145,16 @@ class TransferFinishChecklist
{
if (in_array($item['type'], ['github_app_webhook', 'github_app_urls'], true)) {
$githubApp = GithubApp::where('uuid', $item['subject'])->first();
$url = $githubApp?->settingsUrl();
return $githubApp ? [$githubApp->settingsUrl(), 'Open on GitHub'] : null;
// The html_url of a GitHub App comes from the bundle of another instance, so only web links are shown.
return $url && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true) ? [$url, 'Open on GitHub'] : null;
}
return filled($item['link'] ?? null) ? [$item['link'], $item['link_label'] ?? 'Open'] : null;
$link = $item['link'] ?? null;
// Saved links are paths of this instance.
return is_string($link) && preg_match('#^/(?![/\\\\])#', $link) ? [$link, $item['link_label'] ?? 'Open'] : null;
}
public function fixLabel(array $item): ?string
@@ -228,6 +233,10 @@ class TransferFinishChecklist
$items = [
$this->item('server_connection', 'server', 'Connection to the server'),
$this->item('sentinel', 'server', 'Sentinel sends its data to this instance'),
// The SSH key moved with the server, so the previous instance still has a copy of it.
$this->item('previous_ssh_key', 'server', 'Remove the SSH key of the previous instance from the server',
'The previous instance keeps a copy of the SSH key. Add a new key of this instance to the server and select it here, then remove the old public key from ~/.ssh/authorized_keys on the server.',
[], route('server.private-key', ['server_uuid' => $server->uuid], false), 'Open private key'),
];
$githubApps = $applications->map(fn (Application $application) => $application->source)
@@ -89,7 +89,7 @@
@click.prevent="copyPublicKeyToClipboard({{ Js::from($privateKey->public_key) }})">
Copy public key
</x-forms.button>
@if (data_get($server, 'privateKey.uuid') !== $privateKey->uuid)
@if (data_get($server, 'privateKey.uuid') !== $privateKey->uuid && ! $server->isTransferredAway())
<x-forms.button canGate="update" :canResource="$server"
wire:click="setPrivateKey({{ $privateKey->id }})">
Use this key
@@ -222,11 +222,11 @@
<x-forms.input canGate="update" :canResource="$server" type="password" id="ip"
label="IP address or domain"
helper="Enter a hostname or IP address without http:// or https://."
required :disabled="$isValidating" />
required :disabled="$isValidating || $server->isTransferredAway()" />
<x-forms.input canGate="update" :canResource="$server" id="user" label="SSH user"
required :disabled="$isValidating" />
required :disabled="$isValidating || $server->isTransferredAway()" />
<x-forms.input canGate="update" :canResource="$server" type="number" id="port"
label="SSH port" required :disabled="$isValidating" />
label="SSH port" required :disabled="$isValidating || $server->isTransferredAway()" />
</div>
<div class="mt-4 grid gap-4 lg:grid-cols-3">
@@ -87,11 +87,11 @@
class="listbox-panel top-full! right-0! left-auto! z-[90]! mt-1! w-40! min-w-40!">
@unless ($manualOnly)
<button type="button" class="listbox-option justify-start!" role="menuitem"
wire:click="checkStep('{{ $step['key'] }}')" @click="menu = false">Check now</button>
wire:click="checkStep({{ \Illuminate\Support\Js::from($step['key']) }})" @click="menu = false">Check now</button>
@endunless
@if (! $manualOnly || $link)
<button type="button" class="listbox-option justify-start!" role="menuitem"
wire:click="markStepDone('{{ $step['key'] }}')" @click="menu = false">Mark as done</button>
wire:click="markStepDone({{ \Illuminate\Support\Js::from($step['key']) }})" @click="menu = false">Mark as done</button>
@endif
@if ($fixLabel && $link)
<a href="{{ $link }}" {!! $linkAttributes !!} class="listbox-option justify-start!"
@@ -101,11 +101,11 @@
</div>
@endif
@if ($fixLabel)
<x-forms.button wire:click="fixStep('{{ $step['key'] }}')">{{ $fixLabel }}</x-forms.button>
<x-forms.button wire:click="fixStep({{ \Illuminate\Support\Js::from($step['key']) }})">{{ $fixLabel }}</x-forms.button>
@elseif ($link)
<a href="{{ $link }}" {!! $linkAttributes !!} class="button">{{ $linkLabel }}</a>
@elseif ($manualOnly)
<x-forms.button wire:click="markStepDone('{{ $step['key'] }}')">Mark as done</x-forms.button>
<x-forms.button wire:click="markStepDone({{ \Illuminate\Support\Js::from($step['key']) }})">Mark as done</x-forms.button>
@endif
</div>
@endcan
@@ -34,6 +34,14 @@
</x-slot:actions>
<div class="flex flex-col gap-4">
@if (! $server->isTransferredAway() && ($markerConflict = data_get($server->server_metadata, 'transfer.owner_marker_conflict')))
<x-callout type="warning" title="Another instance may claim this server" data-testid="owner-marker-conflict">
The owner file on the server names a claim of
{{ data_get($markerConflict, 'instance_url') ?: 'an unknown instance' }}, but it is older than the
claim of this instance or its URL is not valid, so this instance keeps managing the server.
If you did not transfer the server, check who can write files on it.
</x-callout>
@endif
@if ($server->isTransferredAway())
<div>
<h3 class="text-sm font-semibold text-neutral-950 dark:text-fg">Transfer back</h3>
@@ -51,11 +59,18 @@
placeholder="Paste token from that instance" autocomplete="off"
helper="A token with root, or with read:sensitive and write, from an admin or owner of that instance." />
<x-forms.input id="thisInstanceUrl" label="URL of this instance" required
helper="The URL at which the other instance reaches this instance. This instance gives it a token that expires in 3 hours." />
helper="The URL at which the other instance reaches this instance. This instance gives it a token that expires in 1 hour." />
</div>
<div>
@php
// A static string: x-text shows the typed host as text, also after the modal was rendered.
$transferBackWarning = 'This instance sends a new API token of your team to '
.'<strong data-testid="transfer-back-host" x-text="(() => { try { return new URL($wire.managerUrl).host } catch (e) { return \'an invalid URL\' } })()"></strong>. '
.'Continue only if you trust that instance.';
@endphp
<x-modal-confirmation title="Transfer this server back?" submitAction="transferBack"
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Transfer back"
:warningMessage="$transferBackWarning"
:actions="[
'The other instance checks the server, this instance, and the tokens. If a check fails, nothing changes.',
'The other instance transfers the server, its resources, and its credentials back here.',
@@ -150,7 +165,7 @@
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
The target shows these steps on the server page, checks them where it can, and keeps
their state.
@if ($targetServerUrl = data_get($migration, 'target_server_url'))
@if ($targetServerUrl = $this->targetServerUrl)
<a href="{{ $targetServerUrl }}" target="_blank" rel="noopener noreferrer"
class="underline">Open the server on the target</a>.
@endif
+113 -4
View File
@@ -280,6 +280,72 @@ describe('POST /api/v1/servers/import', function () {
});
});
describe('POST /api/v1/servers/import with an unsafe bundle', function () {
test('rejects the whole bundle before it writes anything', function (Closure $tamper, string $error) {
$export = transferExportForApiTest($this->server);
$this->application->forceDelete();
$this->server->forceDelete();
$tamper($export);
$counts = fn () => [Server::withTrashed()->count(), PrivateKey::count(), Project::count(), Application::withTrashed()->count(), GithubApp::count()];
$before = $counts();
$response = $this->withHeaders(transferHeaders($this->importToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
->assertStatus(422);
expect(collect($response->json('errors.bundle'))->contains(fn (string $message) => str_contains($message, $error)))->toBeTrue((string) $response->getContent())
->and($counts())->toBe($before);
Queue::assertNotPushed(ValidateAndInstallServerJob::class);
Process::assertNothingRan();
})->with([
'server uuid with shell syntax' => [function (array &$bundle) {
$bundle['server']['uuid'] = 'x;id;#';
}, 'server.uuid is not a valid id'],
'private key uuid with command substitution' => [function (array &$bundle) {
$bundle['private_key']['uuid'] = 'a $(id)';
}, 'private_key.uuid is not a valid id'],
'project uuid with path traversal' => [function (array &$bundle) {
$bundle['projects'][0]['uuid'] = '../x';
}, 'projects.0.uuid is not a valid id'],
'reference uuid with path traversal' => [function (array &$bundle) {
$bundle['projects'][0]['environments'][0]['applications'][0]['destination_uuid'] = '../../etc';
}, 'destination_uuid is not a valid id'],
'javascript git source url' => [function (array &$bundle) {
$bundle['github_apps'] = [['uuid' => 'evil-app', 'name' => 'evil', 'html_url' => 'javascript:alert(1)', 'api_url' => 'https://api.github.com']];
}, 'github_apps.0.html_url'],
'invalid application fqdn' => [function (array &$bundle) {
$bundle['projects'][0]['environments'][0]['applications'][0]['attributes']['fqdn'] = 'javascript:alert(document.cookie)';
}, 'applications.0.fqdn'],
'invalid scheduled task cron' => [function (array &$bundle) {
$bundle['projects'][0]['environments'][0]['applications'][0]['scheduled_tasks'] = [['name' => 'task', 'command' => 'echo hi', 'frequency' => 'every full moon']];
}, 'scheduled_tasks.0.frequency is not a valid cron expression'],
'storage path with shell syntax' => [function (array &$bundle) {
$bundle['projects'][0]['environments'][0]['applications'][0]['persistent_storages'] = [['name' => 'data', 'mount_path' => '/data;id', 'host_path' => null]];
}, 'persistent_storages.0.mount_path'],
'destination network with shell syntax' => [function (array &$bundle) {
$bundle['destinations'][0]['network'] = 'coolify;id';
}, 'destinations.0.network'],
'too many warnings' => [function (array &$bundle) {
$bundle['warnings'] = array_fill(0, 1001, 'x');
}, 'A bundle can have at most 1000 warnings'],
]);
test('a uuid that another team uses does not say that the other team has it', function () {
$export = transferExportForApiTest($this->server);
$appUuid = $this->application->uuid;
$this->server->forceDelete();
$this->application->forceFill(['environment_id' => Environment::factory()->create([
'project_id' => Project::factory()->create(['team_id' => Team::factory()->create()->id])->id,
])->id])->saveQuietly();
$response = $this->withHeaders(transferHeaders($this->importToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
->assertStatus(422);
expect($response->json('errors.bundle.0'))->toBe("The application UUID {$appUuid} cannot be used on this instance. Adopt mode needs the same UUID to keep the running containers. If you deleted the application on this instance, delete it permanently first.");
});
});
describe('POST /api/v1/servers/import claim', function () {
test('import takes management of the server and rebinds Sentinel', function () {
$export = transferExportForApiTest($this->server);
@@ -320,7 +386,7 @@ describe('POST /api/v1/servers/{uuid}/transfer/complete', function () {
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [
'export_id' => 'exp-123',
'target_instance_url' => 'https://coolify-b.test',
'target_instance_url' => 'https://8.8.8.8',
]);
$response->assertOk()
@@ -330,7 +396,50 @@ describe('POST /api/v1/servers/{uuid}/transfer/complete', function () {
expect($server->settings->force_disabled)->toBeTrue()
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('transferred')
->and(data_get($server->server_metadata, 'transfer.export_id'))->toBe('exp-123')
->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test');
->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://8.8.8.8');
});
});
describe('POST /api/v1/servers/{uuid}/transfer/complete with an unsafe URL', function () {
test('rejects a target instance URL that a transfer back must not send a token to', function (string $url) {
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", ['target_instance_url' => $url])
->assertStatus(422)
->assertJsonValidationErrors(['target_instance_url']);
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
})->with([
'javascript' => 'javascript:alert(1)',
'plain http' => 'http://8.8.8.8',
'private address' => 'https://10.0.0.5',
]);
});
describe('GET /api/v1/servers/{uuid}/transfer for a member', function () {
test('shows the transfer state, but not its log and error', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = $member->createToken('member-read', ['read'], teamId: $this->team->id)->plainTextToken;
$this->server->forceFill(['server_metadata' => ['migration' => [
'status' => 'failed',
'target_url' => 'https://8.8.8.8',
'error' => 'secret error',
'log' => [['at' => now()->toIso8601String(), 'level' => 'error', 'message' => 'secret log']],
]]])->save();
$this->withHeaders(transferHeaders($memberToken))
->getJson("/api/v1/servers/{$this->server->uuid}/transfer")
->assertOk()
->assertJsonPath('migration.status', 'failed')
->assertJsonMissingPath('migration.log')
->assertJsonMissingPath('migration.error');
app('auth')->forgetGuards();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/transfer")
->assertOk()
->assertJsonPath('migration.error', 'secret error')
->assertJsonPath('migration.log.0.message', 'secret log');
});
});
@@ -341,7 +450,7 @@ describe('full transfer flow A to B on same process', function () {
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [
'export_id' => $export['export_id'],
'target_instance_url' => 'https://coolify-b.test',
'target_instance_url' => 'https://8.8.8.8',
])
->assertOk();
@@ -498,7 +607,7 @@ describe('GET /api/v1/servers/import/check', function () {
$this->withHeaders(transferHeaders($this->importToken))
->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip='.urlencode($ip).'&port=22')
->assertStatus(422)
->assertJsonPath('message', fn ($message) => str_contains($message, 'cannot connect to a private, loopback, or link-local address'));
->assertJsonPath('message', fn ($message) => str_contains($message, 'is a private, loopback, or link-local address'));
})->with(['127.0.0.1', '10.0.0.5', '169.254.169.254', '::1', 'localhost']);
test('on Cloud, rate limits the reachability probe', function () {
@@ -315,3 +315,91 @@ test('team members cannot start a transfer back', function () {
Http::assertNothingSent();
});
test('the transfer log cannot be polled after the user lost the admin role', function () {
$component = Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
$component->call('refreshMigration')->assertForbidden();
});
test('the tokens are not kept in the component after a failed action', function () {
Queue::fake();
Http::fake(['*' => Http::response(['message' => 'Unauthenticated.'], 401)]);
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->set('targetUrl', 'https://8.8.8.8')
->set('targetToken', 'secret-target-token')
->call('migrateServer')
->assertDispatched('error')
->assertSet('targetToken', '');
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8');
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->set('managerToken', 'secret-manager-token')
->call('transferBack')
->assertDispatched('error')
->assertSet('managerToken', '');
});
test('the URL of an owner marker is not prefilled for a transfer back', function () {
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'claimed', 'claim' => ['claim_id' => 'own', 'claimed_at' => now()->subDay()->toIso8601String()]]]])->save();
app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server->fresh(), json_encode([
'claim_id' => 'other', 'instance_url' => 'https://8.8.8.8', 'claimed_at' => now()->toIso8601String(),
]));
expect($this->server->fresh()->isTransferredAway())->toBeTrue();
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertSet('managerUrl', '')
->assertSee('data-testid="transfer-back-host"', escape: false);
});
test('a stored target URL that is not a safe instance URL is not prefilled for a transfer back', function () {
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'http://10.0.0.5:8000');
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertSet('managerUrl', '');
});
test('the transfer page does not link to a server URL that is not https', function () {
$this->server->forceFill(['server_metadata' => ['migration' => [
'status' => 'succeeded',
'target_url' => 'https://8.8.8.8',
'checklist' => ['A step.'],
'target_server_url' => 'javascript:alert(document.cookie)',
]]])->save();
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertSee('Finish the transfer on the target')
->assertDontSee('javascript:alert', escape: false);
});
test('the transfer page warns about an untrusted owner marker', function () {
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'claimed', 'claim' => ['claim_id' => 'own', 'claimed_at' => now()->toIso8601String()]]]])->save();
app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server->fresh(), json_encode([
'claim_id' => 'forged', 'instance_url' => 'https://evil.example', 'claimed_at' => now()->subDay()->toIso8601String(),
]));
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertSee('Another instance may claim this server')
->assertSee('https://evil.example');
});
test('deleting an archive keeps applications that use the server only as an additional destination', function () {
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();
$sharedApplication = Application::factory()->create(['destination_id' => $otherDestination->id, 'destination_type' => $otherDestination->getMorphClass()]);
$sharedApplication->additional_servers()->attach($this->server->id, ['standalone_docker_id' => $destination->id]);
$application = Application::factory()->create(['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid])->call('delete', $this->userPassword);
Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($application));
Queue::assertNotPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($sharedApplication));
expect($sharedApplication->additional_servers()->count())->toBe(0);
});
@@ -245,14 +245,14 @@ it('gives the job enough time for every connection check step', function () {
->and($lock->expiresAfter)->toBeGreaterThan($job->timeout);
});
it('stops managing a server when its owner marker names another instance', function () {
it('stops managing a server when its owner marker names a newer claim of another instance', function () {
$server = createServerForConnectionIsolationTest([
'ip' => '203.0.113.10',
'server_metadata' => ['transfer' => ['status' => 'claimed', 'claim' => ['claim_id' => 'claim-of-this-instance']]],
'server_metadata' => ['transfer' => ['status' => 'claimed', 'claim' => ['claim_id' => 'claim-of-this-instance', 'claimed_at' => '2026-01-01T00:00:00+00:00']]],
]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
Process::fake([
'*ls -la /*' => Process::result(output: '{"claim_id":"claim-of-instance-b","instance_url":"https://coolify-b.test"}', exitCode: 0),
'*ls -la /*' => Process::result(output: '{"claim_id":"claim-of-instance-b","instance_url":"https://8.8.8.8","claimed_at":"2026-02-01T00:00:00+00:00"}', exitCode: 0),
'*' => Process::result(output: '{"Server":{"Version":"29.4.3"}}', exitCode: 0),
]);
@@ -260,7 +260,7 @@ it('stops managing a server when its owner marker names another instance', funct
$server->refresh();
expect($server->isTransferredAway())->toBeTrue()
->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test')
->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://8.8.8.8')
->and((bool) $server->settings->force_disabled)->toBeTrue();
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'docker version'));
});
+33 -2
View File
@@ -151,7 +151,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain("-o ControlPath='/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}'")
->toContain('-o ControlPersist=3600')
->toContain("'sh -c \"if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi\"' << \\")
->not->toContain('<< $delimiter');
@@ -159,6 +159,37 @@ it('adds mux options to ssh commands only after the explicit master is ready', f
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
});
it('quotes the mux socket and key paths in every local ssh command', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
// Ids with shell syntax, as an import bundle could have had them before ids were validated.
$server->forceFill(['uuid' => 'x;touch /tmp/pwn;#'])->saveQuietly();
$server->privateKey->forceFill(['uuid' => 'a $(id)'])->saveQuietly();
$server = Server::find($server->id);
Storage::disk('ssh-keys')->put('ssh_key@a $(id)', $server->privateKey->private_key);
Process::fake([
'*-O check*' => Process::result(exitCode: 1),
'*-fN *' => Process::result(exitCode: 0),
]);
$socket = escapeshellarg('/var/www/html/storage/app/ssh/mux/mux_x;touch /tmp/pwn;#');
$key = escapeshellarg($server->privateKey->getKeyLocation());
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, 'echo ok');
$scpCommand = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest');
foreach ([$sshCommand, $scpCommand] as $command) {
expect($command)->toContain("-o ControlPath={$socket} ")->toContain("-i {$key} ");
}
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')
&& str_contains($process->command, "-o ControlPath={$socket} ")
&& str_contains($process->command, "-i {$key} "));
Process::assertRan(fn ($process) => str_contains($process->command, "ssh -O check -o ControlPath={$socket} "));
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'mux_x;touch /tmp/pwn;# ')
|| str_contains($process->command, '-i /') || str_contains($process->command, 'ControlPath=/'));
});
it('can generate terminal ssh commands without a hard command timeout', function () {
config(['constants.ssh.mux_enabled' => false]);
$server = makeMuxServer();
@@ -200,7 +231,7 @@ it('adds mux options to scp commands only after the explicit master is ready', f
expect($command)
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain("-o ControlPath='/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}'")
->toContain('-o ControlPersist=3600');
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
@@ -1,6 +1,7 @@
<?php
use App\Services\ServerTransfer\ServerTransferBundle;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
@@ -75,3 +76,19 @@ test('validate accepts a bundle from an older Coolify version', function () {
expect(ServerTransferBundle::validate($bundle)['valid'])->toBeTrue();
});
test('validate accepts Coolify ids and legacy RFC 4122 uuids', function () {
$bundle = bundleTestMinimalBundle();
$bundle['server']['uuid'] = new_public_id();
$bundle['private_key']['uuid'] = (string) Str::uuid();
$bundle['volume_backups'] = [['uuid' => 'abc_DEF-123', 's3_storage_uuids' => [(string) Str::uuid()], 'frequency' => 'daily']];
expect(ServerTransferBundle::validate($bundle)['errors'])->toBe([]);
});
test('validate rejects an unsafe id in an id list', function () {
$bundle = bundleTestMinimalBundle();
$bundle['volume_backups'] = [['s3_storage_uuids' => ['ok', '../../x']]];
expect(ServerTransferBundle::validate($bundle)['errors'])->toContain('volume_backups.0.s3_storage_uuids.1 is not a valid id. An id may only contain letters, digits, hyphens, and underscores (at most 64 characters).');
});
@@ -96,6 +96,8 @@ test('claim writes the owner marker with its claim id on the host', function ()
});
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;
@@ -107,18 +109,20 @@ test('claim changes nothing when the owner marker cannot be written', function (
->and($this->server->settings->sentinel_custom_url)->toBe($sentinelUrlBefore);
});
test('a server is released when its owner marker names the claim of another instance', function () {
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://coolify-b.test',
'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://coolify-b.test')
->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();
});
@@ -288,3 +292,67 @@ test('a GitHub webhook does not queue a runner for a transferred server', functi
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();
});
@@ -29,7 +29,15 @@ use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\Team;
@@ -232,6 +240,21 @@ test('every exported value arrives unchanged on the target', function () {
ServiceApplication::class => ['fqdn' => 'https://fuzz-service.example.test', 'domain_port_overrides' => null, 'noindex_domains' => []],
GithubRunnerConfig::class => ['labels' => ['fuzz-label'], 'max_runners' => 5, 'runner_image' => 'ghcr.io/fuzz/runner:2', 'cpu_limit' => '1.5', 'memory_limit' => '2g', 'capacity_wait_timeout' => 33, 'idle_timeout' => 44, 'job_timeout' => 55],
LocalFileVolume::class => ['fs_path' => '/data/coolify/applications/fuzz/app.conf', 'mount_path' => '/etc/fuzz/app.conf', 'chown' => '1000:1000', 'chmod' => '0640'],
// The import validates these values like the API does.
GithubApp::class => ['html_url' => 'https://github.fuzz.example.test', 'api_url' => 'https://api.github.fuzz.example.test'],
GitlabApp::class => ['html_url' => 'https://gitlab.fuzz.example.test', 'api_url' => 'https://gitlab.fuzz.example.test/api/v4'],
GiteaApp::class => ['html_url' => 'https://gitea.fuzz.example.test', 'api_url' => 'https://gitea.fuzz.example.test/api/v1'],
ScheduledTask::class => ['frequency' => '*/7 * * * *'],
ScheduledDatabaseBackup::class => ['frequency' => '0 3 * * *'],
ScheduledVolumeBackup::class => ['frequency' => '0 4 * * *'],
StandalonePostgresql::class => ['postgres_user' => 'fuzz_user', 'postgres_db' => 'fuzz_db', 'ports_mappings' => '15432:5432', 'public_port' => 15432],
StandaloneMongodb::class => ['mongo_initdb_root_username' => 'fuzz_user', 'mongo_initdb_database' => 'fuzz_db', 'ports_mappings' => '17017:27017', 'public_port' => 17017],
StandaloneMysql::class => ['mysql_user' => 'fuzz_user', 'mysql_database' => 'fuzz_db', 'ports_mappings' => '13306:3306', 'public_port' => 13306],
StandaloneMariadb::class => ['mariadb_user' => 'fuzz_user', 'mariadb_database' => 'fuzz_db', 'ports_mappings' => '13307:3306', 'public_port' => 13307],
StandaloneClickhouse::class => ['clickhouse_admin_user' => 'fuzz_user', 'ports_mappings' => '18123:8123', 'public_port' => 18123],
StandaloneRedis::class => ['ports_mappings' => '16379:6379', 'public_port' => 16379],
StandaloneKeydb::class => ['ports_mappings' => '16380:6379', 'public_port' => 16380],
StandaloneDragonfly::class => ['ports_mappings' => '16381:6379', 'public_port' => 16381],
];
foreach (array_keys(ServerTransferCoverage::TRANSFERRED) as $class) {
$class::query()->withoutGlobalScopes()->get()
@@ -487,6 +487,8 @@ test('the patch check frequency moves with the server and the last patch check r
});
test('a transfer back replaces the archive of the same team in the import transaction', function () {
// The claim restarts Sentinel with its new token; this test is about the resource containers.
Queue::fake();
$bundle = $this->exporter->export($this->server);
$bundle['server']['name'] = 'changed-on-the-other-instance';
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
@@ -506,6 +508,17 @@ test('a transfer back replaces the archive of the same team in the import transa
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'docker rm') || str_contains($process->command, 'docker stop'));
});
test('the import check of a transfer back needs a free server slot, because the archive does not use one', function () {
config()->set('constants.coolify.self_hosted', false);
$this->team->update(['custom_server_limit' => 1]);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->server->private_key_id, 'ip' => '203.0.113.77']);
$limitCheck = collect($this->importer->importChecks($this->server->uuid, $this->server->ip, $this->team->id))->firstWhere('field', 'server');
expect($limitCheck['ok'])->toBeFalse();
});
test('a failed transfer back keeps the archive', function () {
$bundle = $this->exporter->export($this->server);
$bundle['projects'][0]['environments'][0]['databases'][] = [
@@ -1340,7 +1353,7 @@ test('adopt mode stops the import when a resource uuid is already used', functio
$this->server->forceDelete();
expect(fn () => $this->importer->import($bundle, teamId: $this->team->id, adoptMode: true))
->toThrow(ValidationException::class, "The application UUID {$appUuid} is already used");
->toThrow(ValidationException::class, "The application UUID {$appUuid} cannot be used on this instance.");
expect(Server::where('uuid', $bundle['server']['uuid'])->exists())->toBeFalse();
});
@@ -1850,6 +1863,7 @@ test('the GitHub runner config moves disabled with its GitHub App and the bundle
'On GitHub, open the settings of GitHub App Runner App. Set the Webhook URL to {target_url}/webhooks/source/github/events, the Setup URL to {target_url}/webhooks/source/github/install, and the Callback URL to {target_url}/login/github/app.',
'In GitLab, open Settings > Webhooks of every project that uses GitLab App Team GitLab, and set the webhook URL to {target_url}/webhooks/source/gitlab/events.',
'After the GitHub App sends webhooks to {target_url}, open this server there, check the runner requirements, and enable GitHub runners. Enabling them stops runners that another Coolify instance started on this server.',
'This instance keeps a copy of the SSH key of the server. On {target_url}, add a new SSH key to the server and select it there, then remove the old public key from ~/.ssh/authorized_keys on the server.',
])
->and(implode(' ', $bundle['warnings']))->not->toContain('webhook URL')
->and(implode(' ', $bundle['warnings']))->not->toContain('runner');
@@ -8,8 +8,13 @@ use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\ServerTransferClaimer;
@@ -418,7 +423,7 @@ test('start runs every check and logs which are OK and which failed', function (
], 422)]);
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token'))
->toThrow(RuntimeException::class, '2 of 10 checks failed');
->toThrow(RuntimeException::class, '2 of 13 checks failed');
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'));
expect($log->where('level', 'success')->pluck('message'))->toContain('OK: the target instance accepts the API token.')
@@ -707,3 +712,60 @@ test('start notes system-wide git sources that the target does not have', functi
'the target has no matching App' => [true],
'the target has the App' => [false],
]);
test('a transfer back token has only the import abilities, expires within an hour, and survives the claim', function () {
$user = User::factory()->create();
$this->team->members()->attach($user->id, ['role' => 'owner']);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8');
Http::fake(['https://8.8.8.8/api/v1/servers/*/migrate' => Http::response(['migration' => ['status' => 'running']], 202)]);
app(ServerTransferMigrator::class)->requestTransferBack($this->server->fresh(), $user, 'https://8.8.8.8', 'token-of-b', 'https://coolify-a.test');
$token = $user->tokens()->sole();
expect($token->abilities)->toBe(['write', 'deploy'])
->and($token->expires_at->lte(now()->addHour()))->toBeTrue();
// The other instance still checks the import status with the token after the import claims the server.
Queue::fake();
app(ServerTransferClaimer::class)->claim($this->server->fresh());
expect($user->tokens()->count())->toBe(1);
});
test('start fails while a backup or a scheduled task runs on the server', function (string $kind) {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
$application = Application::where('name', 'app-on-server')->sole();
if ($kind === 'database backup') {
$database = StandalonePostgresql::withoutEvents(fn () => StandalonePostgresql::forceCreate([
'uuid' => new_public_id(), 'name' => 'busy-db', 'postgres_password' => 'secret',
'environment_id' => $application->environment_id, 'destination_id' => $application->destination_id, 'destination_type' => $application->destination_type,
]));
$backup = ScheduledDatabaseBackup::create([
'enabled' => true, 'frequency' => '0 0 * * *', 'save_s3' => false, 'team_id' => $this->team->id,
'database_type' => $database->getMorphClass(), 'database_id' => $database->id,
]);
ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $backup->id, 'status' => 'running']);
} else {
$task = ScheduledTask::factory()->create(['team_id' => $this->team->id, 'application_id' => $application->id]);
ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running']);
}
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token'))
->toThrow(RuntimeException::class, "1 {$kind}(s) on this server are running");
Queue::assertNotPushed(MigrateServerToInstanceJob::class);
})->with(['database backup', 'scheduled task']);
test('migrate keeps only an https link to the server on the target', function (string $serverUrl, ?string $kept) {
Http::fake(['https://8.8.8.8/api/v1/servers/import' => Http::response([
'server_uuid' => $this->server->uuid, 'claimed' => true, 'warnings' => [], 'server_url' => $serverUrl,
], 201)]);
$result = app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token');
expect($result['target_server_url'])->toBe($kept);
})->with([
'https' => ['https://coolify-b.example.com/server/abc', 'https://coolify-b.example.com/server/abc'],
'javascript' => ['javascript:alert(document.cookie)', null],
'plain http' => ['http://coolify-b.example.com/server/abc', null],
]);
@@ -153,7 +153,7 @@ test('I finished closes the list and keeps the open steps as skipped', function
$finish = $this->checklist->of($this->server->fresh());
expect($finish['finished_by'])->toBe($this->user->id)
->and($finish['skipped'])->toBe(['sentinel:server'])
->and($finish['skipped'])->toBe(['sentinel:server', 'previous_ssh_key:server'])
->and($this->checklist->isOpen($this->server->fresh()))->toBeFalse();
});
@@ -162,14 +162,15 @@ test('the panel shows the open steps by group, collapses done steps, and finishe
Livewire::test(TransferFinish::class, ['server' => $this->server])
->assertSee('Finish the transfer')
->assertSee('0 of 2 done.')
->assertSee('0 of 3 done.')
->assertSee('Server')
->assertSee('Restart Sentinel')
->call('markStepDone', 'sentinel:server')
->assertSee('1 of 2 done.')
->assertSee('1 of 3 done.')
->assertSee('1 step is OK or done')
->assertSee('Finish with open steps')
->call('markStepDone', 'server_connection:server')
->call('markStepDone', 'previous_ssh_key:server')
->assertDontSee('Finish with open steps')
->call('finishTransfer')
->assertDontSee('Finish the transfer');
@@ -257,12 +258,12 @@ test('restarting Sentinel waits for its data instead of failing the step at once
test('a changed step is broadcast to the team, and the open panel shows it without a reload', function () {
Event::fake([TransferFinishChanged::class]);
$this->checklist->start($this->server);
$component = Livewire::test(TransferFinish::class, ['server' => $this->server])->assertSee('0 of 2 done.');
$component = Livewire::test(TransferFinish::class, ['server' => $this->server])->assertSee('0 of 3 done.');
$this->checklist->markDone($this->server->fresh(), 'sentinel:server');
Event::assertDispatched(TransferFinishChanged::class, fn ($event) => $event->serverUuid === $this->server->uuid && $event->teamId === $this->team->id);
$component->call('handleTransferFinishChanged', ['serverUuid' => $this->server->uuid])->assertSee('1 of 2 done.');
$component->call('handleTransferFinishChanged', ['serverUuid' => $this->server->uuid])->assertSee('1 of 3 done.');
});
test('the GitHub App steps link to the App settings at GitHub', function () {
@@ -276,3 +277,38 @@ test('the GitHub App steps link to the App settings at GitHub', function () {
->assertSeeHtml('href="https://github.com/organizations/acme/settings/apps/deploy-bot" target="_blank"')
->assertSee('Open on GitHub');
});
test('a step key is passed to Livewire as a JavaScript string, so a quote in a domain cannot run script', function () {
// parse_url keeps quotes in a host, so a domain step can have one in its key.
$host = (string) parse_url("https://x'-alert(1)-'.example.test", PHP_URL_HOST);
$items = $this->checklist->start($this->server);
$metadata = $this->server->fresh()->server_metadata;
$metadata['transfer']['finish']['items'][] = [...$items[0], 'key' => "domain:{$host}", 'type' => 'domain', 'subject' => $host, 'label' => "Domain {$host}"];
$this->server->forceFill(['server_metadata' => $metadata])->save();
$html = Livewire::test(TransferFinish::class, ['server' => $this->server->fresh()])->html();
expect($html)->toContain("checkStep('domain:x\\u0027-alert(1)-\\u0027.example.test')")
->and($html)->not->toContain("Step('domain:x&#039;");
});
test('the SSH key of the previous instance is a manual step', function () {
$item = collect($this->checklist->start($this->server))->firstWhere('key', 'previous_ssh_key:server');
expect($item)->not->toBeNull()
->and($this->checklist->canCheck($item))->toBeFalse()
->and($this->checklist->linkOf($item))->toBe(["/server/{$this->server->uuid}/private-key", 'Open private key']);
$this->checklist->markDone($this->server, 'previous_ssh_key:server');
expect(finishTestItem($this->server, 'previous_ssh_key:server')['status'])->toBe('done');
});
test('a GitHub App link that is not a web URL, or a saved link to another host, is not shown', function () {
finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id]);
$items = collect($this->checklist->start($this->server));
$this->githubApp->forceFill(['html_url' => 'javascript:alert(1)//'])->save();
expect($this->checklist->linkOf($items->firstWhere('key', "github_app_webhook:{$this->githubApp->uuid}")))->toBeNull()
->and($this->checklist->linkOf(['type' => 'git_source', 'link' => '//evil.example/x']))->toBeNull()
->and($this->checklist->linkOf(['type' => 'git_source', 'link' => 'javascript:alert(1)']))->toBeNull();
});
@@ -0,0 +1,251 @@
<?php
use App\Livewire\Server\CloudflareTunnel;
use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow;
use App\Livewire\Server\Show;
use App\Models\CloudProviderToken;
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 Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;
use Livewire\Livewire;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* A transferred server is an archive. Its host fields must not change, because "Enable management"
* connects to that host, and nothing may call the cloud provider for it.
*/
beforeEach(function () {
Server::flushIdentityMap();
Process::fake();
Storage::fake('ssh-keys');
Cache::flush();
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'instance_timezone' => 'UTC', 'fqdn' => 'https://coolify.test']);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->otherKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'private_key' => generateSSHKey('ed25519')['private']]);
$this->cloudToken = CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'ip' => '203.0.113.10',
'user' => 'root',
'port' => 22,
'hetzner_server_id' => 4242,
'cloud_provider_token_id' => $this->cloudToken->id,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
function archiveServerForHostFields(Server $server): Server
{
app(ServerTransferClaimer::class)->markTransferred($server, targetInstanceUrl: 'https://target.test');
return Server::query()->with('settings')->findOrFail($server->id);
}
function patchServerHostFields(object $test, Team $team, User $user, Server $server, array $payload): TestResponse
{
app('auth')->forgetGuards();
$token = $user->createToken('write-token', ['write']);
$token->accessToken->forceFill(['team_id' => $team->id])->save();
return $test->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken, 'Accept' => 'application/json'])
->patchJson('/api/v1/servers/'.$server->uuid, $payload);
}
test('the server page refuses a new host for an archive', function (string $field, mixed $value) {
$server = archiveServerForHostFields($this->server);
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->set($field, $value)
->call('submit')
->assertDispatched('error')
->assertNotDispatched('success');
$server->refresh();
expect($server->ip)->toBe('203.0.113.10')
->and($server->user)->toBe('root')
->and((int) $server->port)->toBe(22);
})->with([
'ip' => ['ip', '198.51.100.9'],
'user' => ['user', 'admin'],
'port' => ['port', 2222],
]);
test('the server page still saves the name of an archive', function () {
$server = archiveServerForHostFields($this->server);
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->set('name', 'old-host')
->call('submit')
->assertDispatched('success');
expect($server->fresh()->name)->toBe('old-host');
});
test('the server page still saves a new host for a managed server', function () {
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->set('ip', '198.51.100.9')
->set('port', 2222)
->call('submit')
->assertDispatched('success');
expect($this->server->fresh()->ip)->toBe('198.51.100.9')
->and((int) $this->server->fresh()->port)->toBe(2222);
});
test('the private key page refuses a new key for an archive', function () {
$server = archiveServerForHostFields($this->server);
Livewire::test(PrivateKeyShow::class, ['server_uuid' => $server->uuid])
->call('setPrivateKey', $this->otherKey->id)
->assertDispatched('error')
->assertNotDispatched('success');
expect($server->fresh()->private_key_id)->toBe($this->privateKey->id);
Process::assertNothingRan();
});
test('the API refuses a new host or key for an archive', function (string $field) {
$server = archiveServerForHostFields($this->server);
$payload = [
'ip' => '198.51.100.9',
'user' => 'admin',
'port' => 2222,
'private_key_uuid' => $this->otherKey->uuid,
];
patchServerHostFields($this, $this->team, $this->user, $server, [$field => $payload[$field]])
->assertStatus(422)
->assertJsonValidationErrors([$field]);
$server->refresh();
expect($server->ip)->toBe('203.0.113.10')
->and($server->user)->toBe('root')
->and((int) $server->port)->toBe(22)
->and($server->private_key_id)->toBe($this->privateKey->id);
})->with(['ip', 'user', 'port', 'private_key_uuid']);
test('the API accepts unchanged host fields and other changes for an archive', function () {
$server = archiveServerForHostFields($this->server);
patchServerHostFields($this, $this->team, $this->user, $server, [
'name' => 'old-host',
'ip' => '203.0.113.10',
'user' => 'root',
'port' => 22,
'private_key_uuid' => $this->privateKey->uuid,
])->assertCreated();
expect($server->fresh()->name)->toBe('old-host');
});
test('the API still changes the host of a managed server', function () {
patchServerHostFields($this, $this->team, $this->user, $this->server, ['ip' => '198.51.100.9', 'port' => 2222])
->assertCreated();
expect($this->server->fresh()->ip)->toBe('198.51.100.9')
->and((int) $this->server->fresh()->port)->toBe(2222);
});
test('enable management uses the server validation limit on Cloud', function () {
config()->set('constants.coolify.self_hosted', false);
instanceSettings()->forceFill(['server_validation_max_failures_per_team' => 1])->save();
$this->team->forceFill(['custom_server_limit' => 5])->save();
$server = archiveServerForHostFields($this->server);
Server::throttleValidation($this->team->id, '192.0.2.1');
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->call('enableManagement')
->assertDispatched('error')
->assertNotDispatched('success');
$server->refresh();
expect($server->isTransferredAway())->toBeTrue()
->and((bool) $server->settings->force_disabled)->toBeTrue();
Process::assertNothingRan();
});
test('the cloud provider state of an archive is not refreshed', function () {
Http::fake();
$this->server->forceFill([
'ip' => '1.2.3.4',
'hetzner_server_status' => 'running',
'vultr_instance_id' => 'vultr-1',
'vultr_instance_status' => 'active',
'digitalocean_droplet_id' => 77,
'digitalocean_droplet_status' => 'active',
'hostinger_virtual_machine_id' => 88,
'hostinger_virtual_machine_status' => 'running',
])->save();
$server = archiveServerForHostFields($this->server);
expect($server->refreshHetznerState())->toBe('running')
->and($server->refreshVultrState())->toBe('active')
->and($server->refreshDigitalOceanState())->toBe('active')
->and($server->refreshHostingerState())->toBe('running')
->and($server->backfillPlaceholderIp('198.51.100.9'))->toBeFalse()
->and($server->fresh()->ip)->toBe('1.2.3.4');
Http::assertNothingSent();
});
test('a member cannot make the server page call the cloud provider for an archive', function () {
Http::fake();
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->server->forceFill(['vultr_instance_id' => 'vultr-1', 'digitalocean_droplet_id' => 77, 'hostinger_virtual_machine_id' => 88])->save();
$server = archiveServerForHostFields($this->server);
$this->actingAs($member);
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->call('checkHetznerServerStatus', true)
->call('checkVultrInstanceStatus', true)
->call('checkDigitalOceanDropletStatus', true)
->call('checkHostingerVirtualMachineStatus', true);
Http::assertNothingSent();
});
test('the server page does not search or link a cloud server for an archive', function () {
Http::fake();
$server = archiveServerForHostFields($this->server);
$server->forceFill(['hetzner_server_id' => null, 'cloud_provider_token_id' => null])->save();
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
->set('selectedHetznerTokenId', $this->cloudToken->id)
->call('searchHetznerServer')
->set('manualHetznerServerId', 9999)
->call('searchHetznerServerById')
->set('matchedHetznerServer', ['id' => 9999])
->call('linkToHetzner');
expect($server->fresh()->hetzner_server_id)->toBeNull();
Http::assertNothingSent();
});
test('the Cloudflare tunnel of an archive cannot be turned on', function () {
$archive = archiveServerForHostFields($this->server);
Livewire::test(CloudflareTunnel::class, ['server_uuid' => $archive->uuid])
->call('manualCloudflareConfig')
->assertDispatched('error');
expect((bool) $archive->settings->fresh()->is_cloudflare_tunnel)->toBeFalse();
});