mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 20:17:05 +00:00
feat(v5): add server reconciliation and canvas APIs
Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens.
This commit is contained in:
1 parent
df854feee8
commit
6ae45684f9
147 files changed
+22444
-10407
No files matched your search
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Generates the ES256 (EC P-256) keypair used to authorize coold host agents
|
||||
* against flux. Laravel signs the per-host JWT with the private key
|
||||
* (config('flux.jwt_private_key_path')); flux verifies it with the matching
|
||||
* public key (config('flux.jwt_public_key_path')). Without this keypair a fresh
|
||||
* install cannot mint host tokens, so this command is a bootstrap prerequisite.
|
||||
*
|
||||
* @see AgentTokenIssuer
|
||||
*/
|
||||
class V5FluxGenerateKeys extends Command
|
||||
{
|
||||
protected $signature = 'v5:flux-generate-keys
|
||||
{--force : Overwrite an existing private key instead of refusing}
|
||||
{--show-public : Print the generated public key PEM so it can be provisioned to flux}';
|
||||
|
||||
protected $description = 'Generate the ES256 keypair Flux uses to sign and verify coold host agent JWTs.';
|
||||
|
||||
public function handle(AgentTokenIssuer $agentTokenIssuer): int
|
||||
{
|
||||
$privateKeyPath = (string) config('flux.jwt_private_key_path');
|
||||
$publicKeyPath = (string) config('flux.jwt_public_key_path');
|
||||
|
||||
if ($privateKeyPath === '' || $publicKeyPath === '') {
|
||||
$this->error('Flux JWT key paths are not configured (flux.jwt_private_key_path / flux.jwt_public_key_path).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Idempotent by default: re-running during provisioning must not clobber
|
||||
// a live key (which would instantly invalidate every host token on
|
||||
// disk). Refuse unless --force is passed, and exit SUCCESS so a
|
||||
// provisioning script can call this unconditionally on every deploy.
|
||||
if (File::exists($privateKeyPath) && ! $this->option('force')) {
|
||||
$this->warn("A Flux JWT private key already exists at {$privateKeyPath}.");
|
||||
$this->line('Refusing to overwrite it. Re-run with --force to replace it (this invalidates every host token currently on disk).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// curve_name drives the actual EC key (P-256). private_key_bits is
|
||||
// still validated by PHP's generic length check (>= 384) even though it
|
||||
// is irrelevant to EC, so it must be present or openssl_pkey_new fails
|
||||
// with "Private key length must be at least 384 bits, configured to 0".
|
||||
$keyPair = openssl_pkey_new([
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_bits' => 384,
|
||||
]);
|
||||
|
||||
if ($keyPair === false) {
|
||||
$this->error('Failed to generate an EC P-256 keypair: '.openssl_error_string());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$privatePem = '';
|
||||
|
||||
if (! openssl_pkey_export($keyPair, $privatePem)) {
|
||||
$this->error('Failed to export the private key PEM: '.openssl_error_string());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($keyPair);
|
||||
|
||||
if ($details === false || ! isset($details['key'])) {
|
||||
$this->error('Failed to read the generated public key PEM.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$publicPem = (string) $details['key'];
|
||||
|
||||
$this->writeKeyFile($privateKeyPath, $privatePem, 0600);
|
||||
$this->writeKeyFile($publicKeyPath, $publicPem, 0644);
|
||||
|
||||
// Self-check: the whole point of this command is that AgentTokenIssuer
|
||||
// can mint with the key we just wrote. If the format were wrong (e.g.
|
||||
// not a PEM EC private key Firebase\JWT accepts for ES256) this fails
|
||||
// loudly here instead of silently at the first real host bootstrap.
|
||||
try {
|
||||
$token = $agentTokenIssuer->issue('flux-keygen-selfcheck');
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error('Generated a keypair but AgentTokenIssuer could not mint a token with it: '.$exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (substr_count($token, '.') !== 2) {
|
||||
$this->error('Generated key produced a malformed JWT (expected 3 segments).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Generated a fresh ES256 (EC P-256) Flux keypair.');
|
||||
$this->line(" Private key (0600): {$privateKeyPath}");
|
||||
$this->line(" Public key (0644): {$publicKeyPath}");
|
||||
$this->newLine();
|
||||
$this->line('Provision the PUBLIC key to flux — flux verifies every host JWT with it.');
|
||||
$this->line('Keep the PRIVATE key secret and on the Laravel host only.');
|
||||
|
||||
if ($this->option('show-public')) {
|
||||
$this->newLine();
|
||||
$this->line(rtrim($publicPem));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a key file with exact permissions, creating the parent directory at
|
||||
* 0700 if missing. chmod is applied after the write because umask can
|
||||
* loosen both the mkdir mode and the created file mode.
|
||||
*/
|
||||
private function writeKeyFile(string $path, string $contents, int $mode): void
|
||||
{
|
||||
$directory = dirname($path);
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
File::makeDirectory($directory, 0700, true);
|
||||
@chmod($directory, 0700);
|
||||
}
|
||||
|
||||
File::put($path, $contents);
|
||||
@chmod($path, $mode);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ use App\Jobs\RegenerateSslCertJob;
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Jobs\UpdateCoolifyJob;
|
||||
use App\Jobs\V5ReconcileServersJob;
|
||||
use App\Jobs\V5RotateAgentTokensJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
@@ -49,6 +51,14 @@ class Kernel extends ConsoleKernel
|
||||
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
|
||||
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
|
||||
|
||||
// V5 reconciliation loop: pull-based safety net for the push-only
|
||||
// coold -> flux -> webhook status pipeline, plus container status pruning.
|
||||
$this->scheduleInstance->job(new V5ReconcileServersJob)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
|
||||
|
||||
// V5 host JWT rotation: re-mints and SSH-pushes a fresh host token
|
||||
// before the on-disk token expires so coold reconnects stay authorized.
|
||||
$this->scheduleInstance->job(new V5RotateAgentTokensJob)->hourly()->withoutOverlapping()->onOneServer();
|
||||
|
||||
if (isDev()) {
|
||||
// Instance Jobs
|
||||
$this->scheduleInstance->command('horizon:snapshot')->everyMinute();
|
||||
|
||||
Reference in new issue
Block a user