Files
coolify/app/Jobs/RotatePrivateKeyJob.php
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

171 lines
7.1 KiB
PHP

<?php
namespace App\Jobs;
use App\Actions\Server\AuthorizedKeys;
use App\Models\PrivateKey;
use App\Models\Server;
use Carbon\CarbonInterval;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Throwable;
/**
* Moves every server that uses the old key to the new key. A server moves only after the new key logs in.
* Servers that fail stay on the old key, so the rotation can run again.
*/
class RotatePrivateKeyJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 1800;
public const RESULT_TTL_SECONDS = 86400;
/** The server moved; RemoveRotatedAuthorizedKeyJob removes the old key later. */
public const OLD_KEY_PENDING = 'moved_old_pending';
public function __construct(
public int $oldPrivateKeyId,
public int $newPrivateKeyId,
public ?int $startedByUserId = null,
) {}
public static function cacheKey(PrivateKey|string $privateKey): string
{
$uuid = $privateKey instanceof PrivateKey ? $privateKey->uuid : $privateKey;
return "private-key-rotation:{$uuid}";
}
public function middleware(): array
{
return [(new WithoutOverlapping("rotate-private-key-{$this->oldPrivateKeyId}"))->expireAfter($this->timeout + 60)->dontRelease()];
}
public function handle(): void
{
$oldKey = PrivateKey::find($this->oldPrivateKeyId);
if (! $oldKey) {
return;
}
$newKey = PrivateKey::where('team_id', $oldKey->team_id)->find($this->newPrivateKeyId);
$state = Cache::get(self::cacheKey($oldKey), []);
if (! $newKey) {
$this->storeState($oldKey, [...$state, 'status' => 'failed', 'error' => 'The new key does not exist anymore.']);
return;
}
$state = [...$state, 'status' => 'running', 'new_private_key_id' => $newKey->id, 'servers' => []];
$servers = Server::where('team_id', $oldKey->team_id)->where('private_key_id', $oldKey->id)->get();
foreach ($servers as $server) {
$result = $this->rotateServer($server, $oldKey, $newKey);
$state['servers'][$server->uuid] = ['name' => $server->name, ...$result];
$this->storeState($oldKey, $state);
auditLog('system.server.ssh_key_rotated', [
'team_id' => $oldKey->team_id,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'old_private_key_uuid' => $oldKey->uuid,
'new_private_key_uuid' => $newKey->uuid,
'result' => $result['status'],
], in_array($result['status'], ['moved', self::OLD_KEY_PENDING], true) ? 'info' : 'warning');
}
$counts = collect($state['servers'])->countBy('status')->all();
$this->storeState($oldKey, [...$state, 'status' => 'finished']);
auditLog('system.private_key.rotated', [
'team_id' => $oldKey->team_id,
'private_key_uuid' => $oldKey->uuid,
'private_key_name' => $oldKey->name,
'new_private_key_uuid' => $newKey->uuid,
'started_by_user_id' => $this->startedByUserId,
'servers' => $counts,
], array_diff(array_keys($counts), ['moved', self::OLD_KEY_PENDING]) === [] ? 'info' : 'warning');
}
public function failed(?Throwable $exception): void
{
$oldKey = PrivateKey::find($this->oldPrivateKeyId);
if ($oldKey) {
$this->storeState($oldKey, [...Cache::get(self::cacheKey($oldKey), []), 'status' => 'failed', 'error' => 'The rotation stopped. Start it again.']);
}
}
/**
* @return array{status: 'moved_old_pending'|'moved_old_not_removed'|'failed'|'old_key_not_found'|'unreachable'|'skipped', message: string}
*/
private function rotateServer(Server $server, PrivateKey $oldKey, PrivateKey $newKey): array
{
if ($server->isTransferredAway()) {
return ['status' => 'skipped', 'message' => 'Another Coolify instance manages this server now.'];
}
if (! AuthorizedKeys::canRunOn($server)) {
return ['status' => 'unreachable', 'message' => 'The server is disabled or has no IP address.'];
}
try {
AuthorizedKeys::add($server, $newKey, $oldKey);
} catch (Throwable $e) {
return match ($e->getCode()) {
AuthorizedKeys::OLD_KEY_NOT_FOUND => ['status' => 'old_key_not_found', 'message' => 'The old key is not in ~/.ssh/authorized_keys of the SSH user, so Coolify cannot replace it there. Add the new key on the server yourself.'],
// ssh exits with 255 when it cannot connect or log in.
255 => ['status' => 'unreachable', 'message' => 'Could not connect: '.self::errorWithoutHost($server, $e)],
default => ['status' => 'failed', 'message' => 'Could not add the new key: '.self::errorWithoutHost($server, $e)],
};
}
$probe = clone $server;
$probe->private_key_id = $newKey->id;
$probe->setRelation('privateKey', $newKey);
['uptime' => $newKeyWorks] = $probe->validateConnection(justCheckingNewKey: true);
if (! $newKeyWorks) {
try {
AuthorizedKeys::undoAdd($server, $newKey, $oldKey);
} catch (Throwable) {
// The server stays on the old key. The next rotation adds the same new key again.
}
return ['status' => 'failed', 'message' => 'The new key could not log in. Check the AuthorizedKeysFile setting of sshd.'];
}
// The saved hook closes the multiplexed connection, so the next command logs in with the new key.
$server->privateKey()->associate($newKey);
$server->save();
$delay = RemoveRotatedAuthorizedKeyJob::delaySeconds($server);
RemoveRotatedAuthorizedKeyJob::dispatch($server->id, $oldKey->id, $oldKey->uuid, $oldKey->publicKeyBody())->delay($delay);
return [
'status' => self::OLD_KEY_PENDING,
'message' => 'Moved to the new key. The old key is removed from the server in '.CarbonInterval::seconds($delay)->cascade()->forHumans(['parts' => 2]).', when jobs that started before the rotation have finished.',
];
}
/**
* @param array<string, mixed> $state
*/
private function storeState(PrivateKey $oldKey, array $state): void
{
Cache::put(self::cacheKey($oldKey), $state, self::RESULT_TTL_SECONDS);
}
/** The UI shows server names only, so remove the host address from SSH errors. */
public static function errorWithoutHost(Server $server, Throwable $e): string
{
$message = str_replace((string) $server->ip, 'the server', remove_iip($e->getMessage()));
return trim(preg_replace('/^ssh: connect to host the server port \d+:\s*/i', '', $message));
}
}