mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
peaklabs-dev
Aditya Tripathi
parent
b7dc2e435b
commit
033f0c84da
@@ -4,6 +4,7 @@ namespace App\Actions\Application;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Jobs\SyncGithubDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
@@ -37,6 +38,13 @@ class CleanupPreviewDeployment
|
||||
'status' => 'success',
|
||||
];
|
||||
|
||||
// GitHub must not show a closed pull request as deployed, also when the server is down.
|
||||
try {
|
||||
SyncGithubDeploymentJob::deactivatePullRequest($application, $pull_request_id);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning("Failed to queue GitHub deployment deactivation for application {$application->id}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$server = $application->destination->server;
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
|
||||
@@ -121,7 +121,7 @@ class StartClickhouse
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneSqlite;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Services\RcloneTransfer;
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use App\Support\DatabaseImport\DatabaseImportCleanup;
|
||||
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
|
||||
@@ -181,7 +182,7 @@ class StartDatabaseImport
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)
|
||||
->where(fn ($query) => $query->whereUuid($source->s3StorageUuid)->orWhere('id', ctype_digit((string) $source->s3StorageUuid) ? (int) $source->s3StorageUuid : -1))
|
||||
->where('is_usable', true)->first();
|
||||
if (! $storage || ! ValidationPatterns::isValidS3BucketName($storage->bucket)) {
|
||||
if (! $storage || ($storage->isS3() && ! ValidationPatterns::isValidS3BucketName($storage->bucket))) {
|
||||
throw new DatabaseImportException('S3 storage was not found or has an invalid bucket.');
|
||||
}
|
||||
$key = ltrim((string) $source->path, '/');
|
||||
@@ -194,8 +195,22 @@ class StartDatabaseImport
|
||||
throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
|
||||
}
|
||||
$helper = "s3-restore-{$operation}";
|
||||
if ($storage->isS3()) {
|
||||
$downloadCommand = $this->startS3Helper($storage, $server, $helper, $network, $key);
|
||||
} else {
|
||||
// The rclone image has the BusyBox tools that streamFromHelper() needs.
|
||||
$transfer = new RcloneTransfer($storage, $server, $helper);
|
||||
try {
|
||||
$transfer->start(network: $network);
|
||||
} catch (Throwable) {
|
||||
instant_remote_process([$transfer->removeCommand()], $server, throwError: false);
|
||||
|
||||
throw new DatabaseImportException('Unable to start the restore helper.');
|
||||
}
|
||||
$downloadCommand = $transfer->downloadCommand($key, '/tmp/restore');
|
||||
}
|
||||
$commandList = [
|
||||
$this->startS3Helper($storage, $server, $helper, $network, $key),
|
||||
$downloadCommand,
|
||||
...$this->streamFromHelper($helper, $container, $containerPath),
|
||||
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
|
||||
];
|
||||
|
||||
@@ -186,7 +186,7 @@ class StartDragonfly
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ class StartKeydb
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ class StartMariadb
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null";
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ class StartMongodb
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem < /dev/null";
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ class StartMysql
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null";
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ class StartPostgresql
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt < /dev/null";
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class StartRedis
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class StartSqlite
|
||||
$readme = generate_readme_file($this->database->name, now());
|
||||
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
||||
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
||||
$this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||
array_push($this->commands, ...$this->pullImageCommands("{$this->configuration_dir}/docker-compose.yml"));
|
||||
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ImageScan;
|
||||
|
||||
use App\Enums\ImageScanStatus;
|
||||
use App\Events\ImageScanUpdated;
|
||||
use App\Jobs\ImageRescanJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ImageSbom;
|
||||
use App\Models\ImageScan;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Services\DockerRegistryLogins;
|
||||
use App\Services\ImageScan\CandidateEvaluator;
|
||||
use App\Services\ImageScan\Finding;
|
||||
use App\Services\ImageScan\GrypeReportParser;
|
||||
use App\Services\ImageScan\IgnoreMatcher;
|
||||
use App\Services\ImageScan\ImageRef;
|
||||
use App\Services\ImageScan\ImageScanPolicyResolver;
|
||||
use App\Services\ImageScan\RelevanceRules;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use App\Services\ImageScan\ScanFailed;
|
||||
use App\Services\ImageScan\ScanLockBusy;
|
||||
use App\Services\ImageScan\ScannerCommands;
|
||||
use App\Services\ImageScan\ScanNotifications;
|
||||
use App\Services\ImageScan\SecurityRules;
|
||||
use App\Services\ImageScan\SyftPackageParser;
|
||||
use App\Services\ImageScan\TagSelector;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Finds newer versions of a scanned image that fix vulnerabilities (spec 7.2 and the candidate rules of spec 15.4):
|
||||
* the same tag with a newer build, the newest tag of the same line, and the next major for information.
|
||||
* Candidates are scanned from the registry on the server: the newer build of the same tag, plus at most
|
||||
* MAX_CANDIDATE_SCANS other tags per image, and DAILY_CANDIDATE_SCANS per server and day; the SBOM of a candidate
|
||||
* digest is kept per server and reused.
|
||||
*
|
||||
* Writes `image_scans.candidates` as `{status, checked_at, error, tags_truncated, entries}`, with status `skipped` when
|
||||
* only the base image was checked. The base image of a Dockerfile build (spec 7.4) is scanned from the registry with the
|
||||
* newest tag of its line; the result is under `base`: `{image, status, checked_at, error, tags_truncated, attributable,
|
||||
* attributable_ids, current_digest, entries}`. A relevant finding of the app is attributable to the base image when the
|
||||
* current base image has it too. `fixed_ids` and the attributable findings count every medium finding, so a change of
|
||||
* "Count medium problems" needs no new check; the line candidate fixes the attributable findings it does not have. Base entries never make a
|
||||
* finding fixable now. Never throws for registry or scan problems: they become the status. Registry credentials are read only after an anonymous request failed, only for the registry of the image, and are
|
||||
* never stored or logged.
|
||||
*/
|
||||
class RefreshCandidates
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/** Scans of other tags per check (the patch and the line tag, or the major); the same-tag build is extra. */
|
||||
public const MAX_CANDIDATE_SCANS = 2;
|
||||
|
||||
public const DAILY_CANDIDATE_SCANS = 20;
|
||||
|
||||
/** The information-only scan of the newest major leaves this many daily scans for the checks that can fix. */
|
||||
public const MAJOR_SCAN_DAILY_RESERVE = 5;
|
||||
|
||||
/** A check newer than this is not repeated after a scan. The daily rescan is always older. */
|
||||
public const FRESH_HOURS = 20;
|
||||
|
||||
/** ScannerCommands budgets plus 60 s. */
|
||||
private const TIMEOUT_REGISTRY = 90;
|
||||
|
||||
private const TIMEOUT_SBOM = 1260;
|
||||
|
||||
private const TIMEOUT_GRYPE = 360;
|
||||
|
||||
private const TIMEOUT_DEFAULT = 120;
|
||||
|
||||
private const EXIT_TIMEOUT = [124, 137];
|
||||
|
||||
private const EXIT_DOCKER_RUN = 125;
|
||||
|
||||
private ScannerCommands $commands;
|
||||
|
||||
private ImageRef $image;
|
||||
|
||||
private ?string $auth = null;
|
||||
|
||||
private bool $loginRead = false;
|
||||
|
||||
private bool $authRequired = false;
|
||||
|
||||
/** A registry call without credentials worked, so a later failure is not a missing login. */
|
||||
private bool $anonymousWorked = false;
|
||||
|
||||
private bool $tagsTruncated = false;
|
||||
|
||||
private ?string $currentImageId = null;
|
||||
|
||||
private ?int $currentImageSize = null;
|
||||
|
||||
private ?string $error = null;
|
||||
|
||||
private bool $retryWhenBusy = false;
|
||||
|
||||
/**
|
||||
* Marks the check of the scan as queued, so the Security page says that Coolify is checking for newer versions
|
||||
* instead of "Check needed" until the check ends.
|
||||
*/
|
||||
public static function markQueued(ImageScan $scan, int $seconds): void
|
||||
{
|
||||
Cache::put(self::queuedCacheKey($scan), true, $seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued or running RefreshCandidatesJob checks the scan for newer versions.
|
||||
*/
|
||||
public static function isQueued(ImageScan $scan): bool
|
||||
{
|
||||
return Cache::has(self::queuedCacheKey($scan));
|
||||
}
|
||||
|
||||
/**
|
||||
* The check ended. Called before the page refresh, so the page shows the result.
|
||||
*/
|
||||
public static function forgetQueued(ImageScan $scan): void
|
||||
{
|
||||
Cache::forget(self::queuedCacheKey($scan));
|
||||
}
|
||||
|
||||
private static function queuedCacheKey(ImageScan $scan): string
|
||||
{
|
||||
return "image-scan:version-check-queued:{$scan->id}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $lockWaitSeconds A deploy gate waits for the server lock; 0 tries once (background checks).
|
||||
* @param bool $retryWhenBusy Throw ScanLockBusy instead of storing `lock_busy`, so the caller retries later.
|
||||
*
|
||||
* @throws ScanLockBusy
|
||||
*/
|
||||
public function handle(ImageScan $scan, int $lockWaitSeconds = 0, bool $retryWhenBusy = false): void
|
||||
{
|
||||
$this->retryWhenBusy = $retryWhenBusy && $lockWaitSeconds <= 0;
|
||||
$scan = ImageScan::query()->with(['resource', 'server.settings', 'sbom'])->find($scan->id);
|
||||
if (! $scan instanceof ImageScan) {
|
||||
return;
|
||||
}
|
||||
if (! self::needsCandidates($scan)) {
|
||||
ScanNotifications::reevaluate($scan);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server = $scan->server;
|
||||
$resource = $scan->resource;
|
||||
if (! $server instanceof Server || ! ImageRescanJob::canRunOn($server) || ! ImageScanPolicyResolver::for($resource, $server)->scanEnabled) {
|
||||
// No check runs, so an alert that waits for it is sent now.
|
||||
ScanNotifications::reevaluate($scan, checkGaveUp: true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->commands = new ScannerCommands($server, Str::lower(Str::random(24)));
|
||||
$ranOnServer = false;
|
||||
$result = ['status' => 'skipped', 'error' => null, 'entries' => [], 'tags_truncated' => false];
|
||||
$base = null;
|
||||
|
||||
try {
|
||||
if (self::needsImageCandidates($scan)) {
|
||||
$result = $this->guarded(fn () => $this->check($scan, $resource, $lockWaitSeconds, $ranOnServer));
|
||||
}
|
||||
if (self::needsBaseCandidates($scan)) {
|
||||
$base = $this->guarded(fn () => $this->checkBase($scan, $resource, $lockWaitSeconds, $ranOnServer));
|
||||
}
|
||||
} finally {
|
||||
if ($ranOnServer) {
|
||||
instant_remote_process($this->commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
$this->store($scan, $result, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one check with a fresh registry state and adds whether the tag list was cut.
|
||||
*
|
||||
* @param callable(): array{status: string, error: ?string, entries: list<array<string, mixed>>} $check
|
||||
* @return array{status: string, error: ?string, entries: list<array<string, mixed>>, tags_truncated: bool}
|
||||
*/
|
||||
private function guarded(callable $check): array
|
||||
{
|
||||
$this->auth = null;
|
||||
$this->loginRead = false;
|
||||
$this->authRequired = false;
|
||||
$this->anonymousWorked = false;
|
||||
$this->tagsTruncated = false;
|
||||
$this->error = null;
|
||||
|
||||
try {
|
||||
$result = $check();
|
||||
} catch (ScanLockBusy $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
$result = ['status' => 'failed', 'error' => 'unknown', 'entries' => [], 'attributable' => null, 'attributable_ids' => [], 'current_digest' => null];
|
||||
}
|
||||
|
||||
return [...$result, 'tags_truncated' => $this->tagsTruncated];
|
||||
}
|
||||
|
||||
public static function needsCandidates(ImageScan $scan): bool
|
||||
{
|
||||
return self::needsImageCandidates($scan) || self::needsBaseCandidates($scan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidates are looked for only for images the user does not build in Coolify (a Docker image app, or an image a
|
||||
* Compose app pulls), with a relevant finding or an end-of-life distro (spec 15.4).
|
||||
*/
|
||||
private static function needsImageCandidates(ImageScan $scan): bool
|
||||
{
|
||||
$resource = $scan->resource;
|
||||
if ($scan->status !== ImageScanStatus::Completed || ! $resource instanceof Model) {
|
||||
return false;
|
||||
}
|
||||
if ($resource instanceof Application && $resource->build_pack !== 'dockerimage'
|
||||
&& ($resource->build_pack !== 'dockercompose' || $scan->builds_image !== false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::hasEolDistro($scan) || self::relevantFindings($scan, $resource) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A newer base image is looked for on the same trigger when a Dockerfile build has a known base image (spec 15.4).
|
||||
* Nixpacks, Railpack, and static builds have no base image.
|
||||
*/
|
||||
private static function needsBaseCandidates(ImageScan $scan): bool
|
||||
{
|
||||
$resource = $scan->resource;
|
||||
|
||||
return $scan->status === ImageScanStatus::Completed
|
||||
&& $resource instanceof Application
|
||||
&& $resource->build_pack === 'dockerfile'
|
||||
&& filled($scan->base_image_ref)
|
||||
&& (self::hasEolDistro($scan) || self::relevantFindings($scan, $resource) !== []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The relevant findings of the scan (spec 15.2), by the rules of the resource unless `$rules` is given.
|
||||
*
|
||||
* @return list<Finding>
|
||||
*/
|
||||
public static function relevantFindings(ImageScan $scan, Model $resource, ?RelevanceRules $rules = null): array
|
||||
{
|
||||
$rules ??= RelevanceRules::forResource($resource);
|
||||
|
||||
return array_values(array_filter(
|
||||
ResourceScans::findings($scan),
|
||||
fn (Finding $finding): bool => SecurityRules::isRelevant($finding, $rules),
|
||||
));
|
||||
}
|
||||
|
||||
private static function hasEolDistro(ImageScan $scan): bool
|
||||
{
|
||||
return is_array(data_get($scan->summary, 'eol_distro'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The last check finished less than FRESH_HOURS ago with a result that does not need a retry, also for the base image.
|
||||
*/
|
||||
public static function isFresh(ImageScan $scan): bool
|
||||
{
|
||||
$candidates = $scan->candidates;
|
||||
$checkedAt = is_array($candidates) ? ($candidates['checked_at'] ?? null) : null;
|
||||
$fresh = ['ok', 'no_better', 'cannot_compare', 'skipped'];
|
||||
if (! is_string($checkedAt) || ! in_array($scan->candidateStatus(), $fresh, true)) {
|
||||
return false;
|
||||
}
|
||||
if (self::needsBaseCandidates($scan) && ! in_array(data_get($candidates, 'base.status'), $fresh, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return now()->subHours(self::FRESH_HOURS)->lt($checkedAt);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function shouldRefresh(ImageScan $scan): bool
|
||||
{
|
||||
return $scan->exists && ! self::isFresh($scan) && self::needsCandidates($scan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text for a version check (or, with $base, a base check) without a better candidate (spec 7.3, 11). Null when a
|
||||
* candidate is better or nothing was checked.
|
||||
*/
|
||||
public static function message(ImageScan $scan, bool $base = false): ?string
|
||||
{
|
||||
$result = $base ? data_get($scan->candidates, 'base') : $scan->candidates;
|
||||
$status = is_array($result) ? ($result['status'] ?? null) : null;
|
||||
try {
|
||||
$image = ImageRef::parse((string) ($base ? $scan->base_image_ref : $scan->image_ref));
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
$name = (string) ImageRef::parse($image->qualifiedName());
|
||||
$server = (string) ($scan->server?->name ?? 'the server');
|
||||
|
||||
return match ($status) {
|
||||
'no_better' => "No newer version of {$name} is better yet. Coolify checks again every day.",
|
||||
'cannot_compare' => 'Coolify cannot compare versions of this image. Check the image page for a newer version.',
|
||||
'auth_required' => "Coolify cannot read the tags of {$name}. Log in to {$image->registry} on {$server}.",
|
||||
'failed' => data_get($result, 'error') === 'daily_limit'
|
||||
? "Coolify reached the daily limit of version checks on {$server}. It checks again tomorrow."
|
||||
: "Coolify could not check for newer versions of {$name}. It tries again tomorrow.",
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, error: ?string, entries: list<array<string, mixed>>}
|
||||
*/
|
||||
private function check(ImageScan $scan, Model $resource, int $lockWaitSeconds, bool &$ranOnServer): array
|
||||
{
|
||||
try {
|
||||
$this->image = ImageRef::parse((string) $scan->image_ref);
|
||||
} catch (InvalidArgumentException) {
|
||||
return ['status' => 'cannot_compare', 'error' => null, 'entries' => []];
|
||||
}
|
||||
if ($this->image->digest !== null) {
|
||||
return ['status' => 'cannot_compare', 'error' => null, 'entries' => []];
|
||||
}
|
||||
|
||||
$platform = $scan->sbom?->platform;
|
||||
if (! is_string($platform) || $platform === '') {
|
||||
return ['status' => 'failed', 'error' => 'unknown', 'entries' => []];
|
||||
}
|
||||
|
||||
$ranOnServer = true;
|
||||
$this->currentImageId = $scan->image_id;
|
||||
$tag = $this->image->tag ?? 'latest';
|
||||
$selector = new TagSelector;
|
||||
$isVersion = $selector->isVersion($tag);
|
||||
$selected = ['line' => null, 'patch' => null, 'major' => null, 'floating' => false];
|
||||
|
||||
if ($isVersion) {
|
||||
$tags = $this->registry(fn () => $this->commands->tags($this->image->qualifiedName(), $this->auth), 'tags');
|
||||
if ($tags === null) {
|
||||
return $this->failure();
|
||||
}
|
||||
$tags = array_values(array_filter(array_map('trim', explode("\n", $tags)), fn (string $line) => $line !== ''));
|
||||
$this->tagsTruncated = count($tags) > TagSelector::MAX_TAGS;
|
||||
$selected = $selector->select($tag, $tags);
|
||||
}
|
||||
|
||||
// The smallest change first: a newer build of the same tag, the newest patch of the same minor, then the newest
|
||||
// tag of the major. A safer patch ends the search of the line. The same-tag build does not take one of the
|
||||
// MAX_CANDIDATE_SCANS slots, so the patch and the line tag always fit.
|
||||
$entries = [];
|
||||
$scannedDigests = [];
|
||||
$tagScans = 0;
|
||||
if ($scan->repo_digest !== null) {
|
||||
$digest = $this->digest($tag);
|
||||
if ($digest === null && ($this->authRequired || ! $isVersion)) {
|
||||
return $this->failure();
|
||||
}
|
||||
if ($digest !== null && $digest !== $scan->repo_digest) {
|
||||
$scannedDigests[] = $digest;
|
||||
$entries = [...$entries, ...$this->evaluate($scan, $resource, [['kind' => 'same_tag', 'tag' => $tag, 'digest' => $digest]], $platform, $lockWaitSeconds)];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_filter([$selected['patch'], $selected['line']], fn (?string $lineTag): bool => $lineTag !== null && $lineTag !== $tag) as $lineTag) {
|
||||
$digest = $this->digest($lineTag);
|
||||
if ($digest === null) {
|
||||
$this->error ??= 'registry';
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($digest === $scan->repo_digest || in_array($digest, $scannedDigests, true)) {
|
||||
continue;
|
||||
}
|
||||
$scannedDigests[] = $digest;
|
||||
$tagScans++;
|
||||
$lineEntries = $this->evaluate($scan, $resource, [['kind' => 'line', 'tag' => $lineTag, 'digest' => $digest]], $platform, $lockWaitSeconds);
|
||||
$entries = [...$entries, ...$lineEntries];
|
||||
if (($lineEntries[0]['better'] ?? false) === true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($selected['major'] !== null) {
|
||||
$entries[] = $this->majorEntry($scan, $resource, $selected['major'], $platform, $lockWaitSeconds,
|
||||
withScan: ! in_array(true, array_column($entries, 'better'), true) && $tagScans < self::MAX_CANDIDATE_SCANS);
|
||||
}
|
||||
|
||||
$scanned = array_filter($entries, fn (array $entry) => $entry['kind'] !== 'major');
|
||||
|
||||
return [
|
||||
'status' => match (true) {
|
||||
in_array(true, array_column($entries, 'better'), true) => 'ok',
|
||||
$this->authRequired => 'auth_required',
|
||||
$this->error !== null => 'failed',
|
||||
! $isVersion && $scanned === [] => 'cannot_compare',
|
||||
default => 'no_better',
|
||||
},
|
||||
'error' => $this->error,
|
||||
'entries' => $entries,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest tag of a higher major. It is never offered as an update: a major version can need a data migration or
|
||||
* code changes. When nothing of the same major is safer, the check has a scan left, and the server has at least
|
||||
* MAJOR_SCAN_DAILY_RESERVE daily scans left, Coolify scans it, so the page can say whether it would fix the problems
|
||||
* (`fixes_problems`, the "better" rule). Its registry and scan problems, also a busy lock, never change the status
|
||||
* of the check.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function majorEntry(ImageScan $scan, Model $resource, string $majorTag, string $platform, int $lockWaitSeconds, bool $withScan): array
|
||||
{
|
||||
$entry = [
|
||||
'ref' => (string) $this->image->withTag($majorTag),
|
||||
'digest' => null,
|
||||
'kind' => 'major',
|
||||
'better' => false,
|
||||
'fixed_ids' => [],
|
||||
'fixes' => null,
|
||||
'added' => null,
|
||||
'left' => null,
|
||||
'eol' => null,
|
||||
'scanned_at' => null,
|
||||
];
|
||||
if (! $withScan || self::dailyScansLeft($this->commands->server) < self::MAJOR_SCAN_DAILY_RESERVE) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
[$error, $authRequired] = [$this->error, $this->authRequired];
|
||||
try {
|
||||
$digest = $this->digest($majorTag);
|
||||
$scanned = $digest === null ? [] : $this->evaluate($scan, $resource, [['kind' => 'major', 'tag' => $majorTag, 'digest' => $digest]], $platform, $lockWaitSeconds);
|
||||
} catch (ScanLockBusy) {
|
||||
$scanned = [];
|
||||
}
|
||||
[$this->error, $this->authRequired] = [$error, $authRequired];
|
||||
|
||||
if ($scanned === []) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
return [...$scanned[0], 'better' => false, 'fixes_problems' => $scanned[0]['better']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the current base image and the newest tag of its line from the registry (spec 7.4): two candidate scans
|
||||
* with the usual lock, disk, reuse, and daily rules. Only a different tag can change the `FROM` line, so a newer
|
||||
* build of the same tag is not checked. The current base image is scanned also when no newer tag exists or the tag
|
||||
* cannot be compared (not a version, or pinned by digest), so `attributable` is set on every result but a failure,
|
||||
* an unparsable reference, or a truncated scan of the current base image.
|
||||
*
|
||||
* @return array{status: string, error: ?string, entries: list<array<string, mixed>>, attributable: ?int,
|
||||
* attributable_ids: list<string>, current_digest: ?string}
|
||||
*/
|
||||
private function checkBase(ImageScan $scan, Model $resource, int $lockWaitSeconds, bool &$ranOnServer): array
|
||||
{
|
||||
$result = fn (string $status, array $entries = [], ?array $attributable = null, ?string $currentDigest = null): array => [
|
||||
'status' => $status,
|
||||
'error' => $status === 'cannot_compare' ? null : $this->error,
|
||||
'entries' => $entries,
|
||||
'attributable' => $attributable === null ? null : count($attributable),
|
||||
'attributable_ids' => $attributable === null ? [] : array_values(array_unique(array_map(fn (Finding $finding) => $finding->id, $attributable))),
|
||||
'current_digest' => $currentDigest,
|
||||
];
|
||||
$failure = fn (?array $attributable = null, ?string $currentDigest = null): array => [
|
||||
...$result('failed', [], $attributable, $currentDigest),
|
||||
...$this->failure(),
|
||||
];
|
||||
|
||||
try {
|
||||
$this->image = ImageRef::parse((string) $scan->base_image_ref);
|
||||
} catch (InvalidArgumentException) {
|
||||
return $result('cannot_compare');
|
||||
}
|
||||
|
||||
$tag = $this->image->tag ?? 'latest';
|
||||
$selector = new TagSelector;
|
||||
$comparable = $this->image->digest === null && $selector->isVersion($tag);
|
||||
|
||||
$platform = $scan->sbom?->platform;
|
||||
if (! is_string($platform) || $platform === '') {
|
||||
$this->error = 'unknown';
|
||||
|
||||
return $result('failed');
|
||||
}
|
||||
|
||||
$ranOnServer = true;
|
||||
// The disk check of the registry scans uses the size of the app image, an upper bound of its base image.
|
||||
$this->currentImageId = $scan->image_id;
|
||||
$this->currentImageSize = null;
|
||||
// A truncated scan of the current base image cannot tell which findings come from it.
|
||||
$attributableOf = fn (array $currentBase): ?array => $currentBase['truncated']
|
||||
? null
|
||||
: (new CandidateEvaluator)->shared(self::relevantFindings($scan, $resource, new RelevanceRules(IgnoreMatcher::forResource($resource), countMedium: true)), $currentBase['findings']);
|
||||
|
||||
if (! $comparable) {
|
||||
// No newer tag can be chosen, but the current base image (by tag or pinned digest) still tells the origin.
|
||||
$currentDigest = $this->image->digest ?? $this->digest($tag);
|
||||
if ($currentDigest === null) {
|
||||
return $failure();
|
||||
}
|
||||
$currentBase = $this->scanCandidate($currentDigest, $platform, $lockWaitSeconds);
|
||||
if ($currentBase === null) {
|
||||
return $failure(null, $currentDigest);
|
||||
}
|
||||
|
||||
return $result('cannot_compare', [], $attributableOf($currentBase), $currentDigest);
|
||||
}
|
||||
|
||||
$tags = $this->registry(fn () => $this->commands->tags($this->image->qualifiedName(), $this->auth), 'tags');
|
||||
if ($tags === null) {
|
||||
return $failure();
|
||||
}
|
||||
$tags = array_values(array_filter(array_map('trim', explode("\n", $tags)), fn (string $line) => $line !== ''));
|
||||
$this->tagsTruncated = count($tags) > TagSelector::MAX_TAGS;
|
||||
$line = $selector->select($tag, $tags)['line'];
|
||||
$hasLine = $line !== null && $line !== $tag;
|
||||
|
||||
$currentDigest = $this->digest($tag);
|
||||
$lineDigest = $currentDigest === null || ! $hasLine ? null : $this->digest($line);
|
||||
if ($currentDigest === null || ($hasLine && $lineDigest === null)) {
|
||||
return $failure(null, $currentDigest);
|
||||
}
|
||||
|
||||
// The current base image is scanned also without a newer tag, so the actions know which findings come from it.
|
||||
$currentBase = $this->scanCandidate($currentDigest, $platform, $lockWaitSeconds);
|
||||
if ($currentBase === null) {
|
||||
return $failure(null, $currentDigest);
|
||||
}
|
||||
|
||||
$rules = RelevanceRules::forResource($resource);
|
||||
$evaluator = new CandidateEvaluator;
|
||||
$attributable = $attributableOf($currentBase);
|
||||
if (! $hasLine || $lineDigest === $currentDigest) {
|
||||
return $result('no_better', [], $attributable, $currentDigest);
|
||||
}
|
||||
|
||||
$candidate = $this->scanCandidate($lineDigest, $platform, $lockWaitSeconds);
|
||||
if ($candidate === null) {
|
||||
return $failure($attributable, $currentDigest);
|
||||
}
|
||||
|
||||
$candidateFindings = $candidate['findings'];
|
||||
$better = ! $currentBase['truncated'] && ! $candidate['truncated']
|
||||
&& $evaluator->isBetter($currentBase['findings'], $candidateFindings, $rules, $currentBase['eol'], $candidate['eol']);
|
||||
$entry = [
|
||||
'ref' => (string) $this->image->withTag($line),
|
||||
'digest' => $lineDigest,
|
||||
'kind' => 'line',
|
||||
'better' => $better,
|
||||
'fixed_ids' => $evaluator->fixedIds($attributable ?? [], $candidateFindings, $rules->withAllMediums()),
|
||||
'fixes' => $evaluator->counts($evaluator->fixed($attributable ?? [], $candidateFindings)),
|
||||
'added' => $evaluator->added($evaluator->relevant($currentBase['findings'], $rules), $candidateFindings, $rules),
|
||||
'left' => $evaluator->counts($evaluator->relevant($candidateFindings, $rules)),
|
||||
'eol' => $candidate['eol'],
|
||||
'scanned_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
return $result($better ? 'ok' : 'no_better', [$entry], $attributable, $currentDigest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{kind: string, tag: string, digest: string}> $candidates
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function evaluate(ImageScan $scan, Model $resource, array $candidates, string $platform, int $lockWaitSeconds): array
|
||||
{
|
||||
if ($candidates === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rules = RelevanceRules::forResource($resource);
|
||||
$evaluator = new CandidateEvaluator;
|
||||
$current = ResourceScans::findings($scan);
|
||||
$currentRelevant = $evaluator->relevant($current, $rules);
|
||||
$currentEol = data_get($scan->summary, 'eol_distro');
|
||||
$currentEol = is_array($currentEol) ? $currentEol : null;
|
||||
|
||||
$entries = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$report = $this->scanCandidate($candidate['digest'], $platform, $lockWaitSeconds);
|
||||
if ($report === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$findings = $report['findings'];
|
||||
$entries[] = [
|
||||
'ref' => (string) $this->image->withTag($candidate['tag']),
|
||||
'digest' => $candidate['digest'],
|
||||
'kind' => $candidate['kind'],
|
||||
'better' => ! $report['truncated'] && $evaluator->isBetter($current, $findings, $rules, $currentEol, $report['eol']),
|
||||
'fixed_ids' => $evaluator->fixedIds($current, $findings, $rules->withAllMediums()),
|
||||
'fixes' => $evaluator->counts($evaluator->fixed($currentRelevant, $findings)),
|
||||
'added' => $evaluator->added($currentRelevant, $findings, $rules),
|
||||
'left' => $evaluator->counts($evaluator->relevant($findings, $rules)),
|
||||
'eol' => $report['eol'],
|
||||
'scanned_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs Grype on the SBOM of a candidate digest; makes the SBOM from the registry first when this server has none.
|
||||
* Takes the scan lock of the server, so a deploy scan waits at most one candidate scan. A background check never
|
||||
* waits for the lock and leaves it to a deploy gate that waits.
|
||||
*
|
||||
* @return array{findings: list<Finding>, eol: ?array{name: string, version: string}, truncated: bool}|null
|
||||
*/
|
||||
private function scanCandidate(string $digest, string $platform, int $lockWaitSeconds): ?array
|
||||
{
|
||||
$server = $this->commands->server;
|
||||
$lock = ScanImage::serverLock($server);
|
||||
if (! ScanImage::acquireServerLock($lock, $server, $lockWaitSeconds)) {
|
||||
if ($this->retryWhenBusy) {
|
||||
throw new ScanLockBusy;
|
||||
}
|
||||
$this->error ??= 'lock_busy';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$hex = substr($digest, strlen('sha256:'));
|
||||
$sbom = ImageSbom::query()->where('server_id', $server->id)->where('image_id', $digest)->first();
|
||||
|
||||
if ($sbom !== null) {
|
||||
$grype = $this->runStep($this->commands->grype($hex), 'grype', self::TIMEOUT_GRYPE);
|
||||
if ($grype !== null && $grype['code'] === 0) {
|
||||
$sbom->forceFill(['last_used_at' => now()])->save();
|
||||
|
||||
return $this->report($grype);
|
||||
}
|
||||
if ($grype === null || $grype['code'] !== self::EXIT_DOCKER_RUN) {
|
||||
$this->error ??= 'scan';
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->hasDiskSpace()) {
|
||||
$this->error ??= 'disk';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->reserveDailyScan($server)) {
|
||||
$this->error ??= 'daily_limit';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$created = $this->runStep($this->commands->sbomFromRegistry($this->image->qualifiedName().'@'.$digest, $platform, $hex, $this->auth), 'sbom', self::TIMEOUT_SBOM);
|
||||
$packages = $created !== null && $created['code'] === 0 ? $this->runStep($this->commands->packages($hex), 'packages', self::TIMEOUT_GRYPE) : null;
|
||||
if ($packages === null || $packages['code'] !== 0) {
|
||||
$this->error ??= 'scan';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = (new SyftPackageParser)->parse($this->payload($packages)[0]);
|
||||
($sbom ?? new ImageSbom(['server_id' => $server->id, 'image_id' => $digest]))->forceFill([
|
||||
'platform' => $platform,
|
||||
'packages' => array_map(fn ($package) => $package->toArray(), $parsed['packages']),
|
||||
'packages_truncated' => $parsed['truncated'] || $this->payload($packages)[1],
|
||||
'last_used_at' => now(),
|
||||
])->save();
|
||||
|
||||
$grype = $this->runStep($this->commands->grype($hex), 'grype', self::TIMEOUT_GRYPE);
|
||||
if ($grype === null || $grype['code'] !== 0) {
|
||||
$this->error ??= 'scan';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->report($grype);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The disk rule of local scans (image size × 2 + 1 GB), with the size of the current image: Syft writes the
|
||||
* candidate's layers to the scratch directory. A current image that is no longer on the server counts as 0 bytes.
|
||||
*/
|
||||
private function hasDiskSpace(): bool
|
||||
{
|
||||
if ($this->currentImageSize === null) {
|
||||
$this->currentImageSize = 0;
|
||||
if (is_string($this->currentImageId) && preg_match('/\Asha256:[a-f0-9]{64}\z/', $this->currentImageId) === 1) {
|
||||
$inspect = $this->runStep($this->commands->inspect($this->currentImageId), 'inspect', self::TIMEOUT_DEFAULT);
|
||||
$parts = $inspect !== null && $inspect['code'] === 0 ? explode('|', trim($inspect['payload'])) : [];
|
||||
if (count($parts) === 4 && preg_match('/\A\d{1,18}\z/', $parts[3]) === 1) {
|
||||
$this->currentImageSize = (int) $parts[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ScanImage::checkDiskSpace($this->commands, $this->currentImageSize);
|
||||
} catch (ScanFailed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry scans the server has left today.
|
||||
*/
|
||||
private static function dailyScansLeft(Server $server): int
|
||||
{
|
||||
$settings = ServerSetting::query()->where('server_id', $server->id)->first(['image_candidate_scans_today', 'image_candidate_scans_date']);
|
||||
$date = $settings?->image_candidate_scans_date;
|
||||
if ($date === null || ! today()->isSameDay($date)) {
|
||||
return self::DAILY_CANDIDATE_SCANS;
|
||||
}
|
||||
|
||||
return max(0, self::DAILY_CANDIDATE_SCANS - (int) $settings->image_candidate_scans_today);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts a registry scan against the daily limit of the server; false when the limit is reached.
|
||||
*/
|
||||
private function reserveDailyScan(Server $server): bool
|
||||
{
|
||||
$today = today()->toDateString();
|
||||
|
||||
ServerSetting::query()
|
||||
->where('server_id', $server->id)
|
||||
->where(fn ($query) => $query->whereNull('image_candidate_scans_date')->orWhereDate('image_candidate_scans_date', '<', $today))
|
||||
->update(['image_candidate_scans_today' => 0, 'image_candidate_scans_date' => $today]);
|
||||
|
||||
return ServerSetting::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('image_candidate_scans_today', '<', self::DAILY_CANDIDATE_SCANS)
|
||||
->increment('image_candidate_scans_today') > 0;
|
||||
}
|
||||
|
||||
private function digest(string $tag): ?string
|
||||
{
|
||||
$payload = $this->registry(fn () => $this->commands->digest((string) $this->image->withTag($tag), null, $this->auth), 'digest');
|
||||
$digest = $payload === null ? '' : trim($payload);
|
||||
|
||||
return ImageRef::isValidDigest($digest) ? $digest : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a registry command anonymously first. After a failure it reads the server's login for the registry of the
|
||||
* image once and repeats the command with it. Without a usable login, a private image needs one. When an anonymous
|
||||
* call already worked, a failure is a registry error, not a missing login.
|
||||
*
|
||||
* @param callable(): list<string> $commands
|
||||
*/
|
||||
private function registry(callable $commands, string $step): ?string
|
||||
{
|
||||
$result = $this->runStep($commands(), $step, self::TIMEOUT_REGISTRY);
|
||||
if ($result !== null && $result['code'] === 0) {
|
||||
$this->anonymousWorked = $this->anonymousWorked || $this->auth === null;
|
||||
|
||||
return $this->payload($result)[0];
|
||||
}
|
||||
if ($result === null || in_array($result['code'], self::EXIT_TIMEOUT, true)) {
|
||||
$this->error ??= 'registry';
|
||||
|
||||
return null;
|
||||
}
|
||||
if ($this->loginRead || $this->anonymousWorked) {
|
||||
$this->error ??= 'registry';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->loginRead = true;
|
||||
$login = DockerRegistryLogins::registryAuthConfig($this->commands->server, $this->image->registry);
|
||||
if ($login['status'] !== 'ok') {
|
||||
$this->authRequired = $login['status'] === 'helper' || ! $this->image->isDockerOfficial();
|
||||
$this->error ??= 'registry';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->auth = $login['config'];
|
||||
$result = $this->runStep($commands(), $step, self::TIMEOUT_REGISTRY);
|
||||
if ($result !== null && $result['code'] === 0) {
|
||||
return $this->payload($result)[0];
|
||||
}
|
||||
|
||||
$this->authRequired = true;
|
||||
$this->error ??= 'registry';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, error: ?string, entries: list<array<string, mixed>>}
|
||||
*/
|
||||
private function failure(): array
|
||||
{
|
||||
return [
|
||||
'status' => $this->authRequired ? 'auth_required' : 'failed',
|
||||
'error' => $this->authRequired ? 'registry_auth' : ($this->error ?? 'registry'),
|
||||
'entries' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{code: int, size: ?int, payload: string} $grype
|
||||
* @return array{findings: list<Finding>, eol: ?array{name: string, version: string}, truncated: bool}
|
||||
*/
|
||||
private function report(array $grype): array
|
||||
{
|
||||
[$payload, $cut] = $this->payload($grype);
|
||||
$report = (new GrypeReportParser)->parse($payload);
|
||||
|
||||
return ['findings' => $report['findings'], 'eol' => $report['eol'], 'truncated' => $report['truncated'] || $cut];
|
||||
}
|
||||
|
||||
/**
|
||||
* Null when the batch did not run or printed no marker for $step.
|
||||
*
|
||||
* @param list<string> $commands
|
||||
* @return array{code: int, size: ?int, payload: string}|null
|
||||
*/
|
||||
private function runStep(array $commands, string $step, int $timeout): ?array
|
||||
{
|
||||
$output = instant_remote_process($commands, $this->commands->server, throwError: false, timeout: $timeout);
|
||||
$parsed = ScannerCommands::parseOutput($output);
|
||||
if (! array_key_exists($step, $parsed['exit_codes'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['code' => $parsed['exit_codes'][$step], 'size' => $parsed['sizes'][$step] ?? null, 'payload' => $parsed['payload']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Output over the limit was cut by `head -c`: keep only its complete lines.
|
||||
*
|
||||
* @param array{code: int, size: ?int, payload: string} $result
|
||||
* @return array{0: string, 1: bool}
|
||||
*/
|
||||
private function payload(array $result): array
|
||||
{
|
||||
$payload = $result['payload'];
|
||||
$cut = ($result['size'] ?? 0) > ScannerCommands::OUTPUT_LIMIT || strlen($payload) > ScannerCommands::OUTPUT_LIMIT;
|
||||
if ($cut) {
|
||||
$payload = substr($payload, 0, ScannerCommands::OUTPUT_LIMIT);
|
||||
$payload = substr($payload, 0, (int) strrpos($payload, "\n"));
|
||||
}
|
||||
|
||||
return [$payload, $cut];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the result on the current row and recomputes its summary: a better candidate makes findings of images
|
||||
* the user does not build fixable now (spec 7.5).
|
||||
*
|
||||
* @param array{status: string, error: ?string, entries: list<array<string, mixed>>, tags_truncated: bool} $result
|
||||
* @param array{status: string, error: ?string, entries: list<array<string, mixed>>, tags_truncated: bool,
|
||||
* attributable: ?int, attributable_ids: list<string>, current_digest: ?string}|null $base
|
||||
*/
|
||||
private function store(ImageScan $scan, array $result, ?array $base): void
|
||||
{
|
||||
$fresh = ImageScan::query()->find($scan->id);
|
||||
if (! $fresh instanceof ImageScan || ! $scan->resource instanceof Model) {
|
||||
return;
|
||||
}
|
||||
|
||||
$checkedAt = now()->toIso8601String();
|
||||
$fresh->candidates = [
|
||||
'status' => $result['status'],
|
||||
'checked_at' => $checkedAt,
|
||||
'error' => $result['error'],
|
||||
'tags_truncated' => $result['tags_truncated'],
|
||||
'entries' => $result['entries'],
|
||||
...($base === null ? [] : ['base' => [
|
||||
'image' => (string) $scan->base_image_ref,
|
||||
'status' => $base['status'],
|
||||
'checked_at' => $checkedAt,
|
||||
'error' => $base['error'],
|
||||
'tags_truncated' => $base['tags_truncated'],
|
||||
'attributable' => $base['attributable'],
|
||||
'attributable_ids' => $base['attributable_ids'],
|
||||
'current_digest' => $base['current_digest'],
|
||||
'entries' => $base['entries'],
|
||||
]]),
|
||||
];
|
||||
$fresh->setRelation('resource', $scan->resource);
|
||||
ResourceScans::refreshSummaries(collect([$fresh]));
|
||||
|
||||
// The alert for a scan waits for its version check, which can also bring a fix (spec 15.8).
|
||||
try {
|
||||
ScanNotifications::afterScan($fresh, candidateCheck: true);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
self::forgetQueued($fresh);
|
||||
$teamId = method_exists($scan->resource, 'team') ? $scan->resource->team()?->id : null;
|
||||
ImageScanUpdated::dispatch($teamId, (string) $scan->resource->getAttribute('uuid'), (string) $fresh->uuid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ImageScan;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Enums\ImageScanErrorCode;
|
||||
use App\Enums\ImageScanStatus;
|
||||
use App\Events\ImageScanUpdated;
|
||||
use App\Jobs\RefreshCandidatesJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ImageSbom;
|
||||
use App\Models\ImageScan;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Services\ImageScan\Finding;
|
||||
use App\Services\ImageScan\GrypeReportParser;
|
||||
use App\Services\ImageScan\IgnoreMatcher;
|
||||
use App\Services\ImageScan\ImageRef;
|
||||
use App\Services\ImageScan\ImageScanPolicyResolver;
|
||||
use App\Services\ImageScan\Package;
|
||||
use App\Services\ImageScan\ResolvedImageScanPolicy;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use App\Services\ImageScan\ScanFailed;
|
||||
use App\Services\ImageScan\ScannerCommands;
|
||||
use App\Services\ImageScan\ScanNotifications;
|
||||
use App\Services\ImageScan\SecurityStatus;
|
||||
use App\Services\ImageScan\SyftPackageParser;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Scans one local image on a server with Syft and Grype and stores the result for a resource (spec 4.1).
|
||||
*
|
||||
* A scan row exists only for an image ID that was inspected. Progress lives in the cache
|
||||
* (`progressCacheKey()`), the last failure too (`lastErrorCacheKey()`).
|
||||
*/
|
||||
class ScanImage
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* The longest run of handle() under the lock: database status, update, and status again, inspect, pull, and
|
||||
* inspect again, Grype on a stored SBOM that is gone, disk check, SBOM, packages, Grype, cleanup. The lock must
|
||||
* not expire under a scan that still runs.
|
||||
*/
|
||||
public const LOCK_TTL = 2 * self::TIMEOUT_DEFAULT + self::TIMEOUT_DB_UPDATE
|
||||
+ 2 * self::TIMEOUT_DEFAULT + self::TIMEOUT_PULL
|
||||
+ self::TIMEOUT_GRYPE + self::TIMEOUT_DEFAULT + self::TIMEOUT_SBOM + 2 * self::TIMEOUT_GRYPE
|
||||
+ self::TIMEOUT_DEFAULT;
|
||||
|
||||
/**
|
||||
* One wait of a deploy gate for the server lock (spec 5); background scans never wait. The gate waits again while
|
||||
* the lock is busy, see `DeploymentImageScan::gateLockWaitSeconds()`.
|
||||
*/
|
||||
public const GATE_LOCK_WAIT_SECONDS = 120;
|
||||
|
||||
/** The deploy time that a gate keeps for its own scan (SBOM and Grype) after it waited for the lock. */
|
||||
public const GATE_SCAN_RESERVE_SECONDS = self::TIMEOUT_SBOM + self::TIMEOUT_GRYPE;
|
||||
|
||||
public const LAST_ERROR_TTL_DAYS = 7;
|
||||
|
||||
/** ScannerCommands budgets plus 60 s. */
|
||||
private const TIMEOUT_DEFAULT = 120;
|
||||
|
||||
private const TIMEOUT_DB_UPDATE = 960;
|
||||
|
||||
private const TIMEOUT_SBOM = 1260;
|
||||
|
||||
private const TIMEOUT_GRYPE = 360;
|
||||
|
||||
private const TIMEOUT_PULL = 660;
|
||||
|
||||
private const DB_MAX_AGE_DAYS = 5;
|
||||
|
||||
private const DISK_RESERVE_BYTES = 1073741824;
|
||||
|
||||
/** Exit codes of `timeout`: stopped, or killed after the grace period. */
|
||||
private const EXIT_TIMEOUT = [124, 137];
|
||||
|
||||
/** Exit code of a failed `docker run`, for example a missing bind mount source. */
|
||||
private const EXIT_DOCKER_RUN = 125;
|
||||
|
||||
private string $runId = '';
|
||||
|
||||
private CarbonImmutable $startedAt;
|
||||
|
||||
private bool $pullWhenMissing = false;
|
||||
|
||||
/**
|
||||
* Never throws for scan problems. Callers such as the deploy gate must use the returned scan: on a failure it is an
|
||||
* unsaved scan with status Failed and an error code, and a completed scan row of the same image keeps its results.
|
||||
* A Failed row is saved only when no row exists yet for the inspected image.
|
||||
*
|
||||
* @param int $lockWaitSeconds A deploy gate waits for the server lock; 0 tries once (background scans).
|
||||
* @param ?string $baseImageRef The base image of the final stage of a Dockerfile build (spec 7.4), stored on the scan.
|
||||
* @param bool $queueCandidateCheck Queue the check for newer versions; the deploy gate queues it itself.
|
||||
* @param bool $retryWhenBusy The caller retries later when the lock is busy: a busy lock is not recorded as the last error.
|
||||
* @param bool $pullWhenMissing Pull an image that is not on the server ("Scan now"). Images that Coolify builds are never pulled.
|
||||
*/
|
||||
public function handle(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment = null, int $lockWaitSeconds = 0, ?string $baseImageRef = null, bool $queueCandidateCheck = true, bool $retryWhenBusy = false, bool $pullWhenMissing = false): ImageScan
|
||||
{
|
||||
$this->runId = Str::lower(Str::random(24));
|
||||
$this->pullWhenMissing = $pullWhenMissing;
|
||||
$this->startedAt = CarbonImmutable::now();
|
||||
$policy = ImageScanPolicyResolver::for($resource, $server);
|
||||
$context = ['server' => (string) $server->name];
|
||||
|
||||
if ($server->isTransferredAway() || ! $policy->scanEnabled) {
|
||||
return $this->unsavedFailure($server, $resource, $imageRef, null, ImageScanErrorCode::ScanningOff, $context);
|
||||
}
|
||||
|
||||
try {
|
||||
ImageRef::parse($imageRef);
|
||||
} catch (InvalidArgumentException) {
|
||||
return $this->failBeforeRow($server, $resource, $imageRef, null, ImageScanErrorCode::Unknown, $context);
|
||||
}
|
||||
|
||||
$lock = self::serverLock($server);
|
||||
if (! self::acquireServerLock($lock, $server, $lockWaitSeconds)) {
|
||||
return $retryWhenBusy
|
||||
? $this->unsavedFailure($server, $resource, $imageRef, null, ImageScanErrorCode::LockBusy, $context)
|
||||
: $this->failBeforeRow($server, $resource, $imageRef, null, ImageScanErrorCode::LockBusy, $context);
|
||||
}
|
||||
|
||||
try {
|
||||
$scan = $this->scan($server, $resource, $imageRef, $deployment, $policy, $context, self::validBaseImageRef($baseImageRef));
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
$this->notifyAfterScan($scan, $deployment?->id);
|
||||
if ($queueCandidateCheck) {
|
||||
$this->queueCandidateCheck($scan);
|
||||
}
|
||||
|
||||
return $scan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records what the notifications covered and alerts for new problems that attackers are using or are likely to
|
||||
* use (spec 15.8). The alert is a queued notification, so a deploy never waits for it.
|
||||
*/
|
||||
private function notifyAfterScan(ImageScan $scan, ?int $deploymentId = null): void
|
||||
{
|
||||
try {
|
||||
ScanNotifications::afterScan($scan, $deploymentId);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for a newer version that fixes the findings in a queued job, after the lock is released, so a deploy never waits for it (spec 7.1).
|
||||
*/
|
||||
private function queueCandidateCheck(ImageScan $scan): void
|
||||
{
|
||||
try {
|
||||
if (RefreshCandidates::shouldRefresh($scan)) {
|
||||
RefreshCandidatesJob::queueCheck($scan);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescans one local image for every resource that runs it on the server (spec 4.5): one SBOM and one Grype run,
|
||||
* then the row of each resource, with its own summary. Never throws for scan problems; a failure is recorded per
|
||||
* resource like in handle(), and completed rows keep their results.
|
||||
*
|
||||
* @param list<array{0: Model, 1: string}> $targets Each resource with the image ref of its container.
|
||||
* @param bool $retryWhenBusy The caller retries later when the lock is busy: a busy lock is not recorded as the last error.
|
||||
* @return list<ImageScan>
|
||||
*/
|
||||
public function rescan(Server $server, string $imageId, array $targets, int $lockWaitSeconds = 0, bool $retryWhenBusy = false): array
|
||||
{
|
||||
$this->runId = Str::lower(Str::random(24));
|
||||
$this->startedAt = CarbonImmutable::now();
|
||||
$context = ['server' => (string) $server->name];
|
||||
|
||||
if ($server->isTransferredAway()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$targets = array_values(array_filter(
|
||||
array_map(fn (array $target): array => [...$target, ImageScanPolicyResolver::for($target[0], $server)], $targets),
|
||||
fn (array $target): bool => $target[2]->scanEnabled,
|
||||
));
|
||||
if ($targets === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (preg_match('/\Asha256:[a-f0-9]{64}\z/', $imageId) !== 1) {
|
||||
return array_map(fn (array $target) => $this->failBeforeRow($server, $target[0], $target[1], null, ImageScanErrorCode::Unknown, $context), $targets);
|
||||
}
|
||||
|
||||
$lock = self::serverLock($server);
|
||||
if (! self::acquireServerLock($lock, $server, $lockWaitSeconds)) {
|
||||
return array_map(fn (array $target) => $retryWhenBusy
|
||||
? $this->unsavedFailure($server, $target[0], $target[1], $imageId, ImageScanErrorCode::LockBusy, $context)
|
||||
: $this->failBeforeRow($server, $target[0], $target[1], $imageId, ImageScanErrorCode::LockBusy, $context), $targets);
|
||||
}
|
||||
|
||||
try {
|
||||
$scans = $this->scanShared($server, $imageId, $targets, $context);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
foreach ($scans as $scan) {
|
||||
$this->notifyAfterScan($scan);
|
||||
}
|
||||
|
||||
return $scans;
|
||||
}
|
||||
|
||||
/**
|
||||
* One scan or scan data change at a time per server (spec 4.1).
|
||||
*/
|
||||
public static function serverLock(Server $server): Lock
|
||||
{
|
||||
return Cache::lock("image-scan:{$server->id}", self::LOCK_TTL, Str::random(40));
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the server lock. A deploy gate ($lockWaitSeconds > 0) that has to wait marks it with
|
||||
* `gateWaitingCacheKey()`, so background scans leave the lock to it. A background run ($lockWaitSeconds 0) tries
|
||||
* once and does not take the lock while a gate waits; it retries later.
|
||||
*/
|
||||
public static function acquireServerLock(Lock $lock, Server $server, int $lockWaitSeconds): bool
|
||||
{
|
||||
if ($lockWaitSeconds <= 0) {
|
||||
return ! self::isGateWaiting($server) && (bool) $lock->get();
|
||||
}
|
||||
|
||||
if ($lock->get()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Cache::put(self::gateWaitingCacheKey($server), true, $lockWaitSeconds + 5);
|
||||
try {
|
||||
return (bool) $lock->block($lockWaitSeconds);
|
||||
} catch (LockTimeoutException) {
|
||||
return false;
|
||||
} finally {
|
||||
Cache::forget(self::gateWaitingCacheKey($server));
|
||||
}
|
||||
}
|
||||
|
||||
public static function gateWaitingCacheKey(Server $server): string
|
||||
{
|
||||
return "image-scan:gate-waiting:{$server->id}";
|
||||
}
|
||||
|
||||
/**
|
||||
* A deploy gate waits for the server lock.
|
||||
*/
|
||||
public static function isGateWaiting(Server $server): bool
|
||||
{
|
||||
return Cache::has(self::gateWaitingCacheKey($server));
|
||||
}
|
||||
|
||||
/**
|
||||
* The scan could not run because the server lock was busy and the caller retries later (see `$retryWhenBusy`).
|
||||
*/
|
||||
public static function lockWasBusy(ImageScan $scan): bool
|
||||
{
|
||||
return ! $scan->exists && $scan->status === ImageScanStatus::Failed && $scan->error_code === ImageScanErrorCode::LockBusy;
|
||||
}
|
||||
|
||||
public static function progressCacheKey(Model $resource): string
|
||||
{
|
||||
return "image-scan:progress:{$resource->getMorphClass()}:{$resource->getKey()}";
|
||||
}
|
||||
|
||||
public static function lastErrorCacheKey(Model $resource): string
|
||||
{
|
||||
return "image-scan:last-error:{$resource->getMorphClass()}:{$resource->getKey()}";
|
||||
}
|
||||
|
||||
/**
|
||||
* A base image reference that ImageRef accepts, else null.
|
||||
*/
|
||||
private static function validBaseImageRef(?string $ref): ?string
|
||||
{
|
||||
if ($ref === null || mb_strlen($ref) > 512) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
ImageRef::parse($ref);
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{server: string} $context
|
||||
*/
|
||||
private function scan(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, ResolvedImageScanPolicy $policy, array $context, ?string $baseImageRef = null): ImageScan
|
||||
{
|
||||
$commands = new ScannerCommands($server, $this->runId);
|
||||
$isBaseline = ! $this->resourceScans($resource)->where('status', ImageScanStatus::Completed)->exists();
|
||||
$image = null;
|
||||
|
||||
try {
|
||||
$this->progress($resource, 'database');
|
||||
$databaseBuiltAt = $this->databaseBuiltAt($commands);
|
||||
$this->progress($resource, 'inspect');
|
||||
$image = $this->inspectOrPull($commands, $resource, $imageRef);
|
||||
$this->progress($resource, 'sbom');
|
||||
[$sbom, $grype] = $this->sbomAndReport($commands, [$resource], $imageRef, $image);
|
||||
|
||||
return $this->complete($server, $resource, $imageRef, $deployment, $policy, $sbom, $this->report($grype), $image, $databaseBuiltAt, $isBaseline, $baseImageRef);
|
||||
} catch (ScanFailed $e) {
|
||||
$errorContext = [...$context, ...$e->context];
|
||||
|
||||
return $image === null
|
||||
? $this->failBeforeRow($server, $resource, $imageRef, null, $e->errorCode, $errorContext)
|
||||
: $this->failAfterInspect($server, $resource, $imageRef, $deployment, $image, $e->errorCode, $errorContext);
|
||||
} catch (Throwable $e) {
|
||||
$this->rememberError($resource, ImageScanErrorCode::Unknown, $context);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$this->forgetProgress($resource);
|
||||
instant_remote_process($commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{0: Model, 1: string, 2: ResolvedImageScanPolicy}> $targets
|
||||
* @param array{server: string} $context
|
||||
* @return list<ImageScan>
|
||||
*/
|
||||
private function scanShared(Server $server, string $imageId, array $targets, array $context): array
|
||||
{
|
||||
$commands = new ScannerCommands($server, $this->runId);
|
||||
$resources = array_map(fn (array $target): Model => $target[0], $targets);
|
||||
$isBaseline = array_map(fn (Model $resource): bool => ! $this->resourceScans($resource)->where('status', ImageScanStatus::Completed)->exists(), $resources);
|
||||
$image = null;
|
||||
|
||||
try {
|
||||
$this->progressAll($resources, 'database');
|
||||
$databaseBuiltAt = $this->databaseBuiltAt($commands);
|
||||
$this->progressAll($resources, 'inspect');
|
||||
// By ID, not by a tag that can point to another image by now.
|
||||
$image = $this->inspect($commands, $imageId);
|
||||
if ($image['id'] !== $imageId) {
|
||||
$image = null;
|
||||
|
||||
throw new ScanFailed(ImageScanErrorCode::ImageMissing);
|
||||
}
|
||||
$this->progressAll($resources, 'sbom');
|
||||
[$sbom, $grype] = $this->sbomAndReport($commands, $resources, $imageId, $image);
|
||||
$report = $this->report($grype);
|
||||
|
||||
return array_map(
|
||||
fn (array $target, int $index) => $this->complete($server, $target[0], $target[1], null, $target[2], $sbom, $report, $image, $databaseBuiltAt, $isBaseline[$index]),
|
||||
$targets,
|
||||
array_keys($targets),
|
||||
);
|
||||
} catch (ScanFailed $e) {
|
||||
$errorContext = [...$context, ...$e->context];
|
||||
|
||||
return array_map(fn (array $target) => $image === null
|
||||
? $this->failBeforeRow($server, $target[0], $target[1], null, $e->errorCode, $errorContext)
|
||||
: $this->failAfterInspect($server, $target[0], $target[1], null, $image, $e->errorCode, $errorContext), $targets);
|
||||
} catch (Throwable $e) {
|
||||
foreach ($resources as $resource) {
|
||||
$this->rememberError($resource, ImageScanErrorCode::Unknown, $context);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
foreach ($resources as $resource) {
|
||||
$this->forgetProgress($resource);
|
||||
}
|
||||
instant_remote_process($commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<ImageScan>
|
||||
*/
|
||||
private function resourceScans(Model $resource): Builder
|
||||
{
|
||||
return ImageScan::query()
|
||||
->where('resource_type', $resource->getMorphClass())
|
||||
->where('resource_id', $resource->getKey());
|
||||
}
|
||||
|
||||
private function progress(Model $resource, string $stage): void
|
||||
{
|
||||
Cache::put(self::progressCacheKey($resource), [
|
||||
'run_id' => $this->runId,
|
||||
'stage' => $stage,
|
||||
'started_at' => $this->startedAt->toIso8601String(),
|
||||
], self::LOCK_TTL);
|
||||
$this->broadcast($resource, $this->runId, final: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Model> $resources
|
||||
*/
|
||||
private function progressAll(array $resources, string $stage): void
|
||||
{
|
||||
foreach ($resources as $resource) {
|
||||
$this->progress($resource, $stage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a missing database once. Grype refuses a database older than 5 days.
|
||||
*/
|
||||
private function databaseBuiltAt(ScannerCommands $commands): CarbonImmutable
|
||||
{
|
||||
$builtAt = $this->databaseStatus($commands);
|
||||
|
||||
if ($builtAt === null) {
|
||||
$this->updateDatabase($commands);
|
||||
$builtAt = $this->databaseStatus($commands) ?? throw new ScanFailed(ImageScanErrorCode::DbMissing);
|
||||
}
|
||||
|
||||
if ($builtAt->lt(now()->subDays(self::DB_MAX_AGE_DAYS))) {
|
||||
throw new ScanFailed(ImageScanErrorCode::DbTooOld, ['n' => (int) floor($builtAt->diffInDays(now()))]);
|
||||
}
|
||||
|
||||
return $builtAt;
|
||||
}
|
||||
|
||||
private function updateDatabase(ScannerCommands $commands): void
|
||||
{
|
||||
$url = InstanceSettings::get()->image_scan_db_url;
|
||||
|
||||
try {
|
||||
$update = $commands->dbUpdate(is_string($url) && trim($url) !== '' ? trim($url) : null);
|
||||
} catch (InvalidArgumentException) {
|
||||
return;
|
||||
}
|
||||
|
||||
instant_remote_process($update, $commands->server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE);
|
||||
}
|
||||
|
||||
private function databaseStatus(ScannerCommands $commands): ?CarbonImmutable
|
||||
{
|
||||
$result = $this->runStep($commands->server, $commands->dbStatus(), 'db-status', self::TIMEOUT_DEFAULT);
|
||||
if (in_array($result['code'], self::EXIT_TIMEOUT, true)) {
|
||||
throw new ScanFailed(ImageScanErrorCode::Timeout);
|
||||
}
|
||||
|
||||
$status = $result['code'] === 0 ? json_decode($result['payload'], true) : null;
|
||||
$built = is_array($status) && ($status['valid'] ?? false) === true ? ($status['built'] ?? null) : null;
|
||||
|
||||
if (! is_string($built) || preg_match('/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})\z/', $built) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CarbonImmutable::parse($built);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects the image. With $pullWhenMissing, an image that is not on the server and that Coolify does not build is
|
||||
* pulled first.
|
||||
*
|
||||
* @return array{id: string, hex: string, repo_digest: ?string, platform: string, size: int}
|
||||
*/
|
||||
private function inspectOrPull(ScannerCommands $commands, Model $resource, string $imageRef): array
|
||||
{
|
||||
try {
|
||||
return $this->inspect($commands, $imageRef);
|
||||
} catch (ScanFailed $e) {
|
||||
if ($e->errorCode !== ImageScanErrorCode::ImageMissing || ! $this->pullWhenMissing || self::buildsImage($resource, $imageRef)) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$this->progress($resource, 'pull');
|
||||
$result = $this->runStep($commands->server, $commands->pull($imageRef), 'pull', self::TIMEOUT_PULL);
|
||||
if ($result['code'] !== 0) {
|
||||
throw new ScanFailed(in_array($result['code'], self::EXIT_TIMEOUT, true) ? ImageScanErrorCode::Timeout : ImageScanErrorCode::PullFailed);
|
||||
}
|
||||
$this->progress($resource, 'inspect');
|
||||
|
||||
return $this->inspect($commands, $imageRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, hex: string, repo_digest: ?string, platform: string, size: int}
|
||||
*/
|
||||
private function inspect(ScannerCommands $commands, string $imageRef): array
|
||||
{
|
||||
$result = $this->runStep($commands->server, $commands->inspect($imageRef), 'inspect', self::TIMEOUT_DEFAULT);
|
||||
if ($result['code'] !== 0) {
|
||||
throw new ScanFailed(ImageScanErrorCode::ImageMissing);
|
||||
}
|
||||
|
||||
$parts = explode('|', trim($result['payload']));
|
||||
if (count($parts) !== 4
|
||||
|| preg_match('/\Asha256:([a-f0-9]{64})\z/', $parts[0], $id) !== 1
|
||||
|| preg_match('#\A[a-z0-9]+/[a-z0-9_]+\z#', $parts[2]) !== 1
|
||||
|| preg_match('/\A\d{1,18}\z/', $parts[3]) !== 1) {
|
||||
throw new ScanFailed(ImageScanErrorCode::Unknown);
|
||||
}
|
||||
|
||||
$repoDigest = Str::afterLast($parts[1], '@');
|
||||
|
||||
return [
|
||||
'id' => $parts[0],
|
||||
'hex' => $id[1],
|
||||
'repo_digest' => str_contains($parts[1], '@') && ImageRef::isValidDigest($repoDigest) ? $repoDigest : null,
|
||||
'platform' => $parts[2],
|
||||
'size' => (int) $parts[3],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `docker save` and the SBOM need image size × 2 + 1 GiB on the Docker root and on the scan directory.
|
||||
* Candidate scans from a registry use the same rule (Syft writes the layers to the scratch directory).
|
||||
*
|
||||
* @throws ScanFailed
|
||||
*/
|
||||
public static function checkDiskSpace(ScannerCommands $commands, int $imageSize): void
|
||||
{
|
||||
$output = instant_remote_process($commands->diskFree(), $commands->server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
|
||||
if (preg_match('/(\d+) +(\d+)\s*\z/', (string) $output, $free) !== 1) {
|
||||
throw new ScanFailed(ImageScanErrorCode::Unknown);
|
||||
}
|
||||
|
||||
$needed = $imageSize * 2 + self::DISK_RESERVE_BYTES;
|
||||
if ((int) $free[1] < $needed || (int) $free[2] < $needed) {
|
||||
throw new ScanFailed(ImageScanErrorCode::DiskSpace, ['n' => (int) ceil($needed / self::DISK_RESERVE_BYTES)]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses the SBOM of the image on this server. When its file is gone, Grype cannot mount it: the file is
|
||||
* created once more and the row is updated in place (scans of other resources keep their link).
|
||||
*
|
||||
* @param list<Model> $resources
|
||||
* @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
|
||||
* @return array{0: ImageSbom, 1: array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>}}
|
||||
*/
|
||||
private function sbomAndReport(ScannerCommands $commands, array $resources, string $imageRef, array $image): array
|
||||
{
|
||||
$sbom = ImageSbom::query()->where('server_id', $commands->server->id)->where('image_id', $image['id'])->first();
|
||||
|
||||
if ($sbom !== null) {
|
||||
$this->progressAll($resources, 'vulnerabilities');
|
||||
$grype = $this->runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
|
||||
|
||||
if ($grype['code'] !== self::EXIT_DOCKER_RUN) {
|
||||
$this->assertSucceeded($grype, 'grype');
|
||||
$sbom->forceFill(['last_used_at' => now()])->save();
|
||||
|
||||
return [$sbom, $grype];
|
||||
}
|
||||
|
||||
$this->progressAll($resources, 'sbom');
|
||||
}
|
||||
|
||||
$sbom = $this->createSbom($commands, $imageRef, $image, $sbom);
|
||||
$this->progressAll($resources, 'vulnerabilities');
|
||||
$grype = $this->runStep($commands->server, $commands->grype($image['hex']), 'grype', self::TIMEOUT_GRYPE);
|
||||
$this->assertSucceeded($grype, 'grype');
|
||||
|
||||
return [$sbom, $grype];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
|
||||
*/
|
||||
private function createSbom(ScannerCommands $commands, string $imageRef, array $image, ?ImageSbom $sbom): ImageSbom
|
||||
{
|
||||
self::checkDiskSpace($commands, $image['size']);
|
||||
|
||||
$result = $this->runStep($commands->server, $commands->sbomFromLocal($imageRef, $image['hex']), 'save', self::TIMEOUT_SBOM);
|
||||
// `docker save` has no memory limit: its 137 is always a timeout kill.
|
||||
$this->assertSucceeded($result, 'save');
|
||||
$this->assertSucceeded($result, 'sbom');
|
||||
|
||||
$packages = $this->runStep($commands->server, $commands->packages($image['hex']), 'packages', self::TIMEOUT_GRYPE);
|
||||
$this->assertSucceeded($packages, 'packages');
|
||||
[$payload, $cut] = $this->readPayload($packages);
|
||||
$parsed = (new SyftPackageParser)->parse($payload);
|
||||
|
||||
$values = [
|
||||
'platform' => $image['platform'],
|
||||
'packages' => array_map(fn (Package $package) => $package->toArray(), $parsed['packages']),
|
||||
'packages_truncated' => $parsed['truncated'] || $cut,
|
||||
'last_used_at' => now(),
|
||||
];
|
||||
|
||||
if ($sbom !== null) {
|
||||
$sbom->forceFill($values)->save();
|
||||
|
||||
return $sbom;
|
||||
}
|
||||
|
||||
return ImageSbom::query()->create([
|
||||
'server_id' => $commands->server->id,
|
||||
'image_id' => $image['id'],
|
||||
...$values,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings of the Grype output, sorted by risk.
|
||||
*
|
||||
* @param array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>} $grype
|
||||
* @return array{findings: list<Finding>, eol: ?array{name: string, version: string}, truncated: bool}
|
||||
*/
|
||||
private function report(array $grype): array
|
||||
{
|
||||
[$payload, $cut] = $this->readPayload($grype);
|
||||
$report = (new GrypeReportParser)->parse($payload);
|
||||
$findings = $report['findings'];
|
||||
usort($findings, fn (Finding $a, Finding $b) => ($b->risk ?? 0.0) <=> ($a->risk ?? 0.0));
|
||||
|
||||
return ['findings' => $findings, 'eol' => $report['eol'], 'truncated' => $report['truncated'] || $cut];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{findings: list<Finding>, eol: ?array{name: string, version: string}, truncated: bool} $report
|
||||
* @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
|
||||
*/
|
||||
private function complete(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, ResolvedImageScanPolicy $policy, ImageSbom $sbom, array $report, array $image, CarbonImmutable $databaseBuiltAt, bool $isBaseline, ?string $baseImageRef = null): ImageScan
|
||||
{
|
||||
$findings = $report['findings'];
|
||||
|
||||
$buildsImage = self::buildsImage($resource, $imageRef);
|
||||
$existing = $this->resourceScans($resource)->where('image_id', $image['id'])->with('deploymentQueue:id,pull_request_id')->first();
|
||||
$summary = ResourceScans::summaryFor($findings, IgnoreMatcher::forResource($resource), $report['eol'], $report['truncated']);
|
||||
|
||||
$scan = $this->upsert($resource, $image['id'], [
|
||||
'server_id' => $server->id,
|
||||
'image_sbom_id' => $sbom->id,
|
||||
'repo_digest' => $image['repo_digest'],
|
||||
'image_ref' => mb_substr($imageRef, 0, 512),
|
||||
...$this->deploymentLink($resource, $existing, $deployment),
|
||||
...($baseImageRef !== null ? ['base_image_ref' => $baseImageRef] : []),
|
||||
'status' => ImageScanStatus::Completed,
|
||||
'error_code' => null,
|
||||
'error_context' => null,
|
||||
'summary' => $summary,
|
||||
'findings' => array_map(fn (Finding $finding) => $finding->toArray(), $findings),
|
||||
'builds_image' => $buildsImage,
|
||||
'is_baseline' => $isBaseline,
|
||||
'grype_db_built_at' => $databaseBuiltAt,
|
||||
'scanned_at' => now(),
|
||||
]);
|
||||
$scan->setRelation('resource', $resource);
|
||||
SecurityStatus::store($scan);
|
||||
$scan->save();
|
||||
|
||||
Cache::forget(self::lastErrorCacheKey($resource));
|
||||
$this->forgetProgress($resource);
|
||||
$this->broadcast($resource, (string) $scan->uuid);
|
||||
|
||||
return $scan;
|
||||
}
|
||||
|
||||
/**
|
||||
* The deployment a scan row belongs to, which decides the current scans of an application (all images of one
|
||||
* deployment). A scan without a deployment (rescan, Scan now) joins the latest finished deployment of the
|
||||
* application, unless the row belongs to a newer deployment. A preview never takes over the row of the application.
|
||||
*
|
||||
* @return array{deployment_queue_id?: int}
|
||||
*/
|
||||
private function deploymentLink(Model $resource, ?ImageScan $existing, ?ApplicationDeploymentQueue $deployment): array
|
||||
{
|
||||
$linked = $existing?->deployment_queue_id;
|
||||
$linkedToPreview = $linked !== null && (int) ($existing->deploymentQueue?->pull_request_id ?? 0) !== 0;
|
||||
|
||||
if ($deployment !== null) {
|
||||
return (int) $deployment->pull_request_id !== 0 && $existing !== null && ! $linkedToPreview
|
||||
? []
|
||||
: ['deployment_queue_id' => (int) $deployment->id];
|
||||
}
|
||||
|
||||
if (! $resource instanceof Application || $linkedToPreview) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$latest = ApplicationDeploymentQueue::query()
|
||||
->where('application_id', (string) $resource->getKey())
|
||||
->where('pull_request_id', 0)
|
||||
->where('status', ApplicationDeploymentStatus::FINISHED->value)
|
||||
->max('id');
|
||||
|
||||
return $latest === null || ($linked !== null && (int) $linked >= (int) $latest)
|
||||
? []
|
||||
: ['deployment_queue_id' => (int) $latest];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the row of (resource, image ID). A row that another run inserts at the same time is updated.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
private function upsert(Model $resource, string $imageId, array $values): ImageScan
|
||||
{
|
||||
$scan = $this->firstOrCreateScan($resource, $imageId, $values) ?? throw new ScanFailed(ImageScanErrorCode::Unknown);
|
||||
|
||||
if (! $scan->wasRecentlyCreated) {
|
||||
$scan->forceFill($values)->save();
|
||||
}
|
||||
|
||||
return $scan;
|
||||
}
|
||||
|
||||
/**
|
||||
* `firstOrCreate` catches a unique violation and reads the other row. Its savepoint can roll back that row inside
|
||||
* an outer transaction, so one more attempt is made before giving up.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
private function firstOrCreateScan(Model $resource, string $imageId, array $values): ?ImageScan
|
||||
{
|
||||
$keys = [
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
'resource_id' => $resource->getKey(),
|
||||
'image_id' => $imageId,
|
||||
];
|
||||
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
try {
|
||||
return ImageScan::query()->firstOrCreate($keys, $values);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an existing row of the image (and its results). Saves a Failed row only for an image without one.
|
||||
*
|
||||
* @param array{id: string, hex: string, repo_digest: ?string, platform: string, size: int} $image
|
||||
* @param array<string, int|string> $context
|
||||
*/
|
||||
private function failAfterInspect(Server $server, Model $resource, string $imageRef, ?ApplicationDeploymentQueue $deployment, array $image, ImageScanErrorCode $code, array $context): ImageScan
|
||||
{
|
||||
$values = [
|
||||
'server_id' => $server->id,
|
||||
'repo_digest' => $image['repo_digest'],
|
||||
'image_ref' => mb_substr($imageRef, 0, 512),
|
||||
'deployment_queue_id' => $deployment?->id,
|
||||
'status' => ImageScanStatus::Failed,
|
||||
'error_code' => $code,
|
||||
'error_context' => $context,
|
||||
'builds_image' => self::buildsImage($resource, $imageRef),
|
||||
];
|
||||
|
||||
$scan = $this->firstOrCreateScan($resource, $image['id'], $values);
|
||||
|
||||
if ($scan === null || ! $scan->wasRecentlyCreated) {
|
||||
return $this->failBeforeRow($server, $resource, $imageRef, $image['id'], $code, $context, $image['repo_digest']);
|
||||
}
|
||||
|
||||
$scan->setRelation('resource', $resource);
|
||||
$this->rememberError($resource, $code, $context);
|
||||
$this->forgetProgress($resource);
|
||||
$this->broadcast($resource, (string) $scan->uuid);
|
||||
|
||||
return $scan;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|string> $context
|
||||
*/
|
||||
private function failBeforeRow(Server $server, Model $resource, string $imageRef, ?string $imageId, ImageScanErrorCode $code, array $context, ?string $repoDigest = null): ImageScan
|
||||
{
|
||||
$scan = $this->unsavedFailure($server, $resource, $imageRef, $imageId, $code, $context, $repoDigest);
|
||||
|
||||
// Another run of the resource is scanning: its progress stays, and a busy lock is not its error.
|
||||
$otherRun = data_get(Cache::get(self::progressCacheKey($resource)), 'run_id');
|
||||
$otherRunActive = is_string($otherRun) && $otherRun !== $this->runId;
|
||||
if (! ($otherRunActive && $code === ImageScanErrorCode::LockBusy)) {
|
||||
$this->rememberError($resource, $code, $context);
|
||||
}
|
||||
$this->forgetProgress($resource);
|
||||
$this->broadcast($resource, $this->runId);
|
||||
|
||||
return $scan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the progress of the resource only when it is the progress of this run.
|
||||
*/
|
||||
private function forgetProgress(Model $resource): void
|
||||
{
|
||||
if (data_get(Cache::get(self::progressCacheKey($resource)), 'run_id') === $this->runId) {
|
||||
Cache::forget(self::progressCacheKey($resource));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|string> $context
|
||||
*/
|
||||
private function unsavedFailure(Server $server, Model $resource, string $imageRef, ?string $imageId, ImageScanErrorCode $code, array $context, ?string $repoDigest = null): ImageScan
|
||||
{
|
||||
$scan = (new ImageScan)->forceFill([
|
||||
'server_id' => $server->id,
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
'resource_id' => $resource->getKey(),
|
||||
'image_id' => $imageId,
|
||||
'repo_digest' => $repoDigest,
|
||||
'image_ref' => mb_substr($imageRef, 0, 512),
|
||||
'status' => ImageScanStatus::Failed,
|
||||
'error_code' => $code,
|
||||
'error_context' => $context,
|
||||
]);
|
||||
$scan->setRelation('resource', $resource);
|
||||
|
||||
return $scan;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|string> $context
|
||||
*/
|
||||
private function rememberError(Model $resource, ImageScanErrorCode $code, array $context): void
|
||||
{
|
||||
Cache::put(self::lastErrorCacheKey($resource), [
|
||||
'code' => $code->value,
|
||||
'context' => $context,
|
||||
'at' => now()->toIso8601String(),
|
||||
], now()->addDays(self::LAST_ERROR_TTL_DAYS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the team builds the image: Coolify builds the images of Dockerfile, Nixpacks, Railpack, and static apps,
|
||||
* and the Compose images of a service with `build` (see composeBuildsImage). Docker images of the team are built by
|
||||
* the team unless it says otherwise; Docker Hub official images are not. Other images are pulled.
|
||||
*/
|
||||
public static function buildsImage(Model $resource, string $imageRef): bool
|
||||
{
|
||||
if (! $resource instanceof Application) {
|
||||
return false;
|
||||
}
|
||||
if ($resource->build_pack === 'dockercompose') {
|
||||
return self::composeBuildsImage($resource, $imageRef);
|
||||
}
|
||||
if ($resource->build_pack !== 'dockerimage') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$buildsByTeam = $resource->settings?->image_builds_by_team;
|
||||
if ($buildsByTeam !== null) {
|
||||
return (bool) $buildsByTeam;
|
||||
}
|
||||
|
||||
try {
|
||||
return ! ImageRef::parse($imageRef)->isDockerOfficial();
|
||||
} catch (InvalidArgumentException) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Compose image is built by Coolify when its name is `{app uuid}_{service}` (the name the parser gives a service
|
||||
* with `build` and no `image`), or when a service with `build` in the app's Compose file names the same image
|
||||
* (tag ignored). Every other image of the file is pulled.
|
||||
*/
|
||||
private static function composeBuildsImage(Application $application, string $imageRef): bool
|
||||
{
|
||||
$name = self::imageName($imageRef);
|
||||
if (filled($application->uuid) && str_starts_with($name, strtolower($application->uuid).'_')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$compose = Yaml::parse((string) ($application->docker_compose ?: $application->docker_compose_raw));
|
||||
} catch (ParseException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ((array) data_get($compose, 'services', []) as $service) {
|
||||
$image = data_get($service, 'image');
|
||||
if (data_get($service, 'build') !== null && is_string($image) && $image !== '' && self::imageName($image) === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The image reference without tag and digest, lowercase.
|
||||
*/
|
||||
public static function imageName(string $imageRef): string
|
||||
{
|
||||
$name = strtolower(trim(explode('@', $imageRef, 2)[0]));
|
||||
|
||||
return (string) preg_replace('#:[^/]*$#', '', $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one command batch and reads the exit code of $step. No output or no marker means the batch did not run.
|
||||
*
|
||||
* @param list<string> $commands
|
||||
* @return array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>}
|
||||
*/
|
||||
private function runStep(Server $server, array $commands, string $step, int $timeout): array
|
||||
{
|
||||
$output = instant_remote_process($commands, $server, throwError: false, timeout: $timeout);
|
||||
$parsed = ScannerCommands::parseOutput($output);
|
||||
|
||||
if ($output === null || trim($output) === '' || ! array_key_exists($step, $parsed['exit_codes'])) {
|
||||
throw new ScanFailed(ImageScanErrorCode::Unknown);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $parsed['exit_codes'][$step],
|
||||
'size' => $parsed['sizes'][$step] ?? null,
|
||||
'payload' => $parsed['payload'],
|
||||
'exit_codes' => $parsed['exit_codes'],
|
||||
'oom' => $parsed['oom'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws for a failed $step of the batch. Only Docker's own `OOMKilled` (the `coolify-scan-oom` line of the
|
||||
* memory-limited Syft and Grype containers) means out of memory; a 137 without it was killed by `timeout`.
|
||||
*
|
||||
* @param array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>} $result
|
||||
*/
|
||||
private function assertSucceeded(array $result, string $step): void
|
||||
{
|
||||
$code = $result['exit_codes'][$step] ?? null;
|
||||
if ($code !== 0 && $step !== 'save' && in_array($step, $result['oom'], true)) {
|
||||
throw new ScanFailed(ImageScanErrorCode::OutOfMemory);
|
||||
}
|
||||
if (in_array($code, self::EXIT_TIMEOUT, true)) {
|
||||
throw new ScanFailed(ImageScanErrorCode::Timeout);
|
||||
}
|
||||
if ($code !== 0) {
|
||||
throw new ScanFailed(ImageScanErrorCode::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output over the limit was cut by `head -c`: keep only the complete lines that were read.
|
||||
*
|
||||
* @param array{code: int, size: ?int, payload: string, exit_codes: array<string, int>, oom: list<string>} $result
|
||||
* @return array{0: string, 1: bool}
|
||||
*/
|
||||
private function readPayload(array $result): array
|
||||
{
|
||||
$payload = $result['payload'];
|
||||
$cut = ($result['size'] ?? 0) > ScannerCommands::OUTPUT_LIMIT || strlen($payload) > ScannerCommands::OUTPUT_LIMIT;
|
||||
|
||||
if ($cut) {
|
||||
$payload = substr($payload, 0, ScannerCommands::OUTPUT_LIMIT);
|
||||
$lastNewline = strrpos($payload, "\n");
|
||||
$payload = $lastNewline === false ? '' : substr($payload, 0, $lastNewline);
|
||||
}
|
||||
|
||||
return [$payload, $cut];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $final False for a progress update while the scan runs.
|
||||
*/
|
||||
private function broadcast(Model $resource, string $scanUuid, bool $final = true): void
|
||||
{
|
||||
$teamId = method_exists($resource, 'team') ? $resource->team()?->id : null;
|
||||
|
||||
ImageScanUpdated::dispatch($teamId, (string) $resource->getAttribute('uuid'), $scanUuid, $final);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ImageScan;
|
||||
|
||||
use App\Jobs\PrepareImageScannerJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Services\ImageScan\ImageScanningRefused;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
/**
|
||||
* Turns image scanning of a server on or off. Turning it on gets the scanner on the server.
|
||||
*/
|
||||
class SetServerImageScanning
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @throws ImageScanningRefused When the instance does not allow scanning or another instance manages the server.
|
||||
*/
|
||||
public function handle(Server $server, bool $enabled): void
|
||||
{
|
||||
if (! $enabled) {
|
||||
$server->settings->update(['is_image_scan_enabled' => false]);
|
||||
PrepareImageScannerJob::forget($server);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::ensureManagedHere($server);
|
||||
if (! InstanceSettings::get()->is_image_scanning_allowed) {
|
||||
throw new ImageScanningRefused('Image scanning is turned off for this Coolify instance.');
|
||||
}
|
||||
|
||||
$server->settings->update(['is_image_scan_enabled' => true]);
|
||||
self::prepareScanner($server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the scanner on the server again, for a server that scans images.
|
||||
*/
|
||||
public static function prepareScanner(Server $server): void
|
||||
{
|
||||
Cache::put(PrepareImageScannerJob::stateCacheKey($server), PrepareImageScannerJob::STATE_PULLING, now()->addHour());
|
||||
PrepareImageScannerJob::dispatch($server);
|
||||
}
|
||||
|
||||
public static function ensureManagedHere(Server $server): void
|
||||
{
|
||||
if ($server->isTransferredAway()) {
|
||||
throw new ImageScanningRefused('This server is managed by another Coolify instance.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ImageScan;
|
||||
|
||||
use App\Actions\Database\RestartDatabase;
|
||||
use App\Actions\Service\RestartService;
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\ImageScan;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\User;
|
||||
use App\Services\ImageScan\ComposeImageVariable;
|
||||
use App\Services\ImageScan\ImageRef;
|
||||
use App\Services\ImageScan\ImageStartPin;
|
||||
use App\Services\ImageScan\ImageUpdateRefused;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use App\Support\ResourceStartActivity;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use InvalidArgumentException;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Switches a resource to a candidate image that fixes vulnerabilities (spec 7.3). Only a better candidate of the
|
||||
* resource's current scan is accepted. A major version is never accepted.
|
||||
*
|
||||
* A newer tag of the same line changes the configuration, so it stays after later deploys and restarts:
|
||||
* - Docker image app: the tag, then a deploy. Standalone database: `image`, then a restart.
|
||||
* - Service: the image line of every application and database that uses the image, in the compose file too; or the
|
||||
* Coolify environment variable when a variable sets the image (`${IMAGE}` or `repo:${TAG}`); then a restart.
|
||||
* - Compose app: the compose file comes from Git, so Coolify keeps an image override per Compose service
|
||||
* (`docker_compose_image_overrides`) that the parser applies while the file still names the replaced image; or the
|
||||
* environment variable as above; then a deploy.
|
||||
*
|
||||
* A newer build of the same tag changes no configuration: the next start pulls the scanned digest and gives it the tag
|
||||
* (`ImageStartPin`), so the containers run exactly the build that Coolify scanned. Every path puts the old
|
||||
* configuration back when the restart or deploy cannot start.
|
||||
*
|
||||
* @phpstan-type ImageChange array{owner: Model, old: string, tag: string, same_tag: bool, digest: ?string}
|
||||
*/
|
||||
class UpdateResourceImage
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const NO_COMPOSE_SERVICE = 'No service of the compose file runs this image in a way Coolify can change. Change the version in the compose file.';
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException When the user may not update the resource, or the resource belongs to Coolify (id 0).
|
||||
* @throws ImageUpdateRefused When the ref is not a better candidate, or the update cannot start.
|
||||
*/
|
||||
public function handle(Model $resource, string $newRef, User $by): void
|
||||
{
|
||||
$service = match (true) {
|
||||
$resource instanceof Service => $resource,
|
||||
$resource instanceof ServiceApplication, $resource instanceof ServiceDatabase => $resource->service,
|
||||
default => null,
|
||||
};
|
||||
|
||||
$this->authorizeFor($service ?? $resource, $by, (int) $resource->getKey() === 0);
|
||||
|
||||
if ($resource instanceof Application && ! in_array($resource->build_pack, ['dockerimage', 'dockercompose'], true)) {
|
||||
throw new ImageUpdateRefused('Coolify builds the image of this app, so there is no image to update.');
|
||||
}
|
||||
if (! $resource instanceof Application && $service === null && ! in_array($resource::class, STANDALONE_DATABASE_MODELS, true)) {
|
||||
throw new ImageUpdateRefused('Coolify cannot update the image of this resource.');
|
||||
}
|
||||
|
||||
$owners = match (true) {
|
||||
$resource instanceof Service => ResourceScans::owners($resource),
|
||||
default => collect([$resource]),
|
||||
};
|
||||
[$owner, $scan, $candidate] = $this->candidate($owners, $newRef);
|
||||
|
||||
$oldRef = (string) $scan->image_ref;
|
||||
$change = $this->change($owner, $scan, $candidate, $newRef);
|
||||
|
||||
$updated = match (true) {
|
||||
$service !== null => $this->updateService($service, [$change]),
|
||||
$owner instanceof Application && $owner->build_pack === 'dockercompose' => $this->updateComposeApplication($owner, [$change]),
|
||||
$owner instanceof Application => $this->updateApplication($owner, $change),
|
||||
default => $this->updateDatabase($owner, $change),
|
||||
};
|
||||
|
||||
$this->audit($service ?? $owner, $by, $updated, [['old_ref' => $oldRef, 'new_ref' => $newRef, 'kind' => $candidate['kind']]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates several images of one service in one change and one restart: the parts of a merged update ("Update n8n
|
||||
* and n8n runners to 2.10.4."). Each part must be a better candidate of the current scan of its own application
|
||||
* or database. When the restart fails, every image and the compose file are put back.
|
||||
*
|
||||
* @param list<array{owner: Model, ref: string}> $parts
|
||||
*
|
||||
* @throws AuthorizationException When the user may not update the service, or the service belongs to Coolify (id 0).
|
||||
* @throws ImageUpdateRefused When a part is not a better candidate, or the update cannot start.
|
||||
*/
|
||||
public function handleParts(Service $service, array $parts, User $by): void
|
||||
{
|
||||
$this->authorizeFor($service, $by, false);
|
||||
|
||||
if ($parts === []) {
|
||||
throw new ImageUpdateRefused('This update has no images.');
|
||||
}
|
||||
|
||||
$owners = ResourceScans::owners($service);
|
||||
$changes = [];
|
||||
$log = [];
|
||||
foreach ($parts as $part) {
|
||||
$owner = $owners->first(fn (Model $candidate): bool => $candidate->getMorphClass() === $part['owner']->getMorphClass()
|
||||
&& (string) $candidate->getKey() === (string) $part['owner']->getKey());
|
||||
if (! $owner instanceof Model) {
|
||||
throw new ImageUpdateRefused('An image of this update does not belong to this service.');
|
||||
}
|
||||
|
||||
[, $scan, $candidate] = $this->candidate(collect([$owner]), $part['ref']);
|
||||
$changes[] = $this->change($owner, $scan, $candidate, $part['ref']);
|
||||
$log[] = ['old_ref' => (string) $scan->image_ref, 'new_ref' => $part['ref'], 'kind' => $candidate['kind']];
|
||||
}
|
||||
|
||||
$updated = $this->updateService($service, $changes);
|
||||
|
||||
$this->audit($service, $by, $updated, $log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
* @throws ImageUpdateRefused When another Coolify instance manages the server now.
|
||||
*/
|
||||
private function authorizeFor(Model $target, User $by, bool $isCoolifyOwner): void
|
||||
{
|
||||
Gate::forUser($by)->authorize('update', $target);
|
||||
|
||||
if ($isCoolifyOwner || (int) $target->getKey() === 0) {
|
||||
throw new AuthorizationException('Coolify does not change the images of its own resources.');
|
||||
}
|
||||
|
||||
// Another Coolify instance manages a transferred server; this one keeps only an archive of it.
|
||||
$server = $target instanceof Service ? $target->server : $target->getAttribute('destination')?->server;
|
||||
if ($server instanceof Server && $server->isTransferredAway()) {
|
||||
throw new ImageUpdateRefused('Another Coolify instance manages this server now.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $updated
|
||||
* @param non-empty-list<array{old_ref: string, new_ref: string, kind: string}> $changes
|
||||
*/
|
||||
private function audit(Model $target, User $by, array $updated, array $changes): void
|
||||
{
|
||||
auditLog('ui.image_scan.image_updated', [
|
||||
'team_id' => $target->team()?->id,
|
||||
'resource_type' => $target->getMorphClass(),
|
||||
'resource_uuid' => (string) $target->getAttribute('uuid'),
|
||||
'resource_name' => (string) $target->getAttribute('name'),
|
||||
'updated_uuids' => $updated,
|
||||
'old_ref' => $changes[0]['old_ref'],
|
||||
'new_ref' => $changes[0]['new_ref'],
|
||||
'kind' => $changes[0]['kind'],
|
||||
...(count($changes) > 1 ? ['changes' => $changes] : []),
|
||||
'user_id' => $by->id,
|
||||
'actor_id' => $by->id,
|
||||
'actor_name' => $by->name,
|
||||
'actor_email' => $by->email,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner, its current scan, and the candidate entry that matches the ref.
|
||||
*
|
||||
* @param Collection<int, Model> $owners
|
||||
* @return array{0: Model, 1: ImageScan, 2: array<string, mixed>}
|
||||
*/
|
||||
private function candidate(Collection $owners, string $newRef): array
|
||||
{
|
||||
foreach ($owners as $owner) {
|
||||
foreach (ResourceScans::currentFor($owner) as $scan) {
|
||||
foreach ($scan->updateCandidates() as $entry) {
|
||||
if ($entry['ref'] !== $newRef) {
|
||||
continue;
|
||||
}
|
||||
$blocked = self::blockedReason($scan, $owner);
|
||||
if ($blocked !== null) {
|
||||
throw new ImageUpdateRefused($blocked);
|
||||
}
|
||||
|
||||
return [$owner, $scan, $entry];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImageUpdateRefused('This version is not one that fixes vulnerabilities of the current image.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Why Coolify cannot switch the image of a scan, even with a better candidate. Null when it can.
|
||||
*/
|
||||
public static function blockedReason(ImageScan $scan, Model $owner): ?string
|
||||
{
|
||||
if ($owner instanceof Application && $owner->build_pack === 'dockercompose') {
|
||||
$refused = self::composeRefusal($owner);
|
||||
if ($refused !== null) {
|
||||
return $refused;
|
||||
}
|
||||
|
||||
try {
|
||||
if (self::composeMatches($owner, self::composeServices($owner), $owner->activeComposeImageOverrides(), (string) $scan->image_ref) === []) {
|
||||
return self::NO_COMPOSE_SERVICE;
|
||||
}
|
||||
} catch (ImageUpdateRefused $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$image = (string) $owner->getAttribute('image');
|
||||
if (ComposeImageVariable::hasVariable($image) && ComposeImageVariable::parse($image) === null) {
|
||||
return 'The compose file sets this image in a way Coolify cannot change. Change the version in the compose file.';
|
||||
}
|
||||
|
||||
if (ResourceScans::runsOtherImage($scan, $owner)) {
|
||||
return 'The running image is not the scanned image. Scan it again to check for newer versions.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One image change: the owner, the scanned image, the new tag, and for a newer build of the same tag its digest.
|
||||
*
|
||||
* @param array<string, mixed> $candidate
|
||||
* @return ImageChange
|
||||
*/
|
||||
private function change(Model $owner, ImageScan $scan, array $candidate, string $newRef): array
|
||||
{
|
||||
return [
|
||||
'owner' => $owner,
|
||||
'old' => (string) $scan->image_ref,
|
||||
'tag' => (string) ImageRef::parse($newRef)->tag,
|
||||
'same_tag' => $candidate['kind'] === 'same_tag',
|
||||
'digest' => is_string($candidate['digest'] ?? null) ? $candidate['digest'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits the scanned build of a newer build of the same tag for the next start of `$starter`.
|
||||
*
|
||||
* @param ImageChange $change
|
||||
*/
|
||||
private function pin(Model $starter, array $change): void
|
||||
{
|
||||
if ($change['digest'] === null) {
|
||||
throw new ImageUpdateRefused('Coolify does not know the digest of the newer build. Check for newer versions again.');
|
||||
}
|
||||
|
||||
ImageStartPin::put($starter, $change['old'], $change['digest']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ImageChange $change
|
||||
* @return list<string>
|
||||
*/
|
||||
private function updateApplication(Application $application, array $change): array
|
||||
{
|
||||
$oldTag = $application->docker_registry_image_tag;
|
||||
if ($change['same_tag']) {
|
||||
$this->pin($application, $change);
|
||||
} else {
|
||||
$application->docker_registry_image_tag = $change['tag'];
|
||||
$application->save();
|
||||
}
|
||||
|
||||
$this->deploy($application, function () use ($application, $oldTag, $change): void {
|
||||
if (! $change['same_tag']) {
|
||||
$application->docker_registry_image_tag = $oldTag;
|
||||
$application->save();
|
||||
}
|
||||
});
|
||||
|
||||
return [(string) $application->uuid];
|
||||
}
|
||||
|
||||
/**
|
||||
* A Compose app from Git: an image override per Compose service, or the environment variable that sets the image,
|
||||
* then a deploy. Refused while the app deploys its compose file as written.
|
||||
*
|
||||
* @param non-empty-list<ImageChange> $changes
|
||||
* @return list<string>
|
||||
*/
|
||||
private function updateComposeApplication(Application $application, array $changes): array
|
||||
{
|
||||
$refused = self::composeRefusal($application);
|
||||
if ($refused !== null) {
|
||||
throw new ImageUpdateRefused($refused);
|
||||
}
|
||||
|
||||
$services = self::composeServices($application);
|
||||
$oldOverrides = $application->docker_compose_image_overrides;
|
||||
$overrides = $application->activeComposeImageOverrides();
|
||||
$variables = [];
|
||||
$variableServices = [];
|
||||
$pinned = false;
|
||||
|
||||
foreach ($changes as $change) {
|
||||
if ($change['same_tag']) {
|
||||
$this->pin($application, $change);
|
||||
$pinned = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches = self::composeMatches($application, $services, $overrides, $change['old']);
|
||||
if ($matches === []) {
|
||||
throw new ImageUpdateRefused(self::NO_COMPOSE_SERVICE);
|
||||
}
|
||||
foreach ($matches as $name => $match) {
|
||||
if ($match['variable'] !== null) {
|
||||
$variables = $this->addVariable($variables, $match['variable']->key, $match['variable']->valueFor($match['current'], $change['tag']));
|
||||
$variableServices[$name] = $match['variable']->key;
|
||||
} else {
|
||||
$overrides[$name] = ['from' => $services[$name], 'to' => $this->withTag($match['current'], $change['tag'])];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A variable that also sets the image of a service that this update does not change would change that image too.
|
||||
foreach ($services as $name => $image) {
|
||||
$variable = ComposeImageVariable::parse($image);
|
||||
if ($variable !== null && isset($variables[$variable->key]) && ! isset($variableServices[$name])) {
|
||||
throw new ImageUpdateRefused("The variable {$variable->key} also sets the image of {$name}. Change the versions in the compose file.");
|
||||
}
|
||||
}
|
||||
self::refuseOtherVariableUses((string) $application->docker_compose_raw, $variableServices);
|
||||
|
||||
$application->docker_compose_image_overrides = $overrides === [] ? null : $overrides;
|
||||
$application->save();
|
||||
$restoreVariables = $this->setVariables($application, $variables);
|
||||
|
||||
$this->deploy($application, function () use ($application, $oldOverrides, $restoreVariables, $pinned): void {
|
||||
$application->docker_compose_image_overrides = $oldOverrides;
|
||||
$application->save();
|
||||
$restoreVariables();
|
||||
if ($pinned) {
|
||||
ImageStartPin::forget($application);
|
||||
}
|
||||
});
|
||||
|
||||
return [(string) $application->uuid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a deploy of the app; runs `$restore` and refuses when it cannot start.
|
||||
*/
|
||||
private function deploy(Application $application, callable $restore): void
|
||||
{
|
||||
try {
|
||||
$result = queue_application_deployment(application: $application, deployment_uuid: new_public_id());
|
||||
} catch (Throwable $e) {
|
||||
$restore();
|
||||
ImageStartPin::forget($application);
|
||||
report($e);
|
||||
|
||||
throw new ImageUpdateRefused('The deploy could not start. Coolify kept the old image.', previous: $e);
|
||||
}
|
||||
|
||||
if (($result['status'] ?? null) !== 'queued') {
|
||||
$restore();
|
||||
ImageStartPin::forget($application);
|
||||
|
||||
throw new ImageUpdateRefused(($result['status'] ?? null) === 'skipped'
|
||||
? 'A deploy of this app is already queued. Try again when it is done.'
|
||||
: (string) ($result['message'] ?? 'The deploy could not be queued.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ImageChange $change
|
||||
* @return list<string>
|
||||
*/
|
||||
private function updateDatabase(Model $database, array $change): array
|
||||
{
|
||||
$oldImage = (string) $database->getAttribute('image');
|
||||
if ($change['same_tag']) {
|
||||
$this->pin($database, $change);
|
||||
} else {
|
||||
$database->setAttribute('image', $this->withTag($oldImage, $change['tag']));
|
||||
$database->save();
|
||||
}
|
||||
|
||||
$restore = function () use ($database, $oldImage, $change): void {
|
||||
ImageStartPin::forget($database);
|
||||
if (! $change['same_tag']) {
|
||||
$database->setAttribute('image', $oldImage);
|
||||
$database->save();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$result = RestartDatabase::run($database);
|
||||
} catch (Throwable $e) {
|
||||
$restore();
|
||||
report($e);
|
||||
|
||||
throw new ImageUpdateRefused('The restart failed. Coolify kept the old image.', previous: $e);
|
||||
}
|
||||
|
||||
if (is_string($result)) {
|
||||
$restore();
|
||||
|
||||
throw new ImageUpdateRefused($result);
|
||||
}
|
||||
|
||||
return [(string) $database->getAttribute('uuid')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the images of the service to their new tags and restarts it once: the compose file and the image of each
|
||||
* application and database, or the environment variable that sets the image. A newer build of the same tag only
|
||||
* waits its scanned digest for the restart.
|
||||
*
|
||||
* @param non-empty-list<ImageChange> $changes
|
||||
* @return list<string>
|
||||
*/
|
||||
private function updateService(Service $service, array $changes): array
|
||||
{
|
||||
if (ResourceStartActivity::latestRunning((string) $service->uuid) !== null) {
|
||||
throw new ImageUpdateRefused('A start of this service is running. Try again when it is done.');
|
||||
}
|
||||
|
||||
/** @var array<string, array{resource: ServiceApplication|ServiceDatabase, tag: string}> $targets */
|
||||
$targets = [];
|
||||
$variables = [];
|
||||
$variableOwners = [];
|
||||
$variableServices = [];
|
||||
$pinned = false;
|
||||
foreach ($changes as $change) {
|
||||
if ($change['same_tag']) {
|
||||
$this->pin($service, $change);
|
||||
$pinned = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = trim((string) $change['owner']->getAttribute('image'));
|
||||
if (ComposeImageVariable::hasVariable($raw)) {
|
||||
$variable = ComposeImageVariable::parse($raw);
|
||||
$current = ResourceScans::configuredImage($change['owner']);
|
||||
if ($variable === null || ! self::sameImage($current, $change['old'])) {
|
||||
throw new ImageUpdateRefused('The compose file sets this image in a way Coolify cannot change. Change the version in the compose file.');
|
||||
}
|
||||
$this->refuseSharedVariable($service, $variable->key, $changes);
|
||||
$variables = $this->addVariable($variables, $variable->key, $variable->valueFor($current, $change['tag']));
|
||||
$variableOwners[] = (string) $change['owner']->getAttribute('uuid');
|
||||
$variableServices[(string) $change['owner']->getAttribute('name')] = $variable->key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subResources = self::subResourcesUsing($service, $change['old']);
|
||||
if ($subResources->isEmpty()) {
|
||||
throw new ImageUpdateRefused('No application or database of this service runs this image.');
|
||||
}
|
||||
foreach ($subResources as $subResource) {
|
||||
$targets[$subResource->getMorphClass().':'.$subResource->getKey()] = ['resource' => $subResource, 'tag' => $change['tag']];
|
||||
}
|
||||
}
|
||||
|
||||
self::refuseOtherVariableUses((string) $service->docker_compose_raw, $variableServices);
|
||||
|
||||
$oldCompose = $service->docker_compose_raw;
|
||||
$oldImages = array_map(fn (array $target): string => (string) $target['resource']->image, $targets);
|
||||
|
||||
if ($targets !== []) {
|
||||
$compose = $this->composeWithImages($service, $targets);
|
||||
DB::transaction(function () use ($service, $compose, $targets): void {
|
||||
$service->docker_compose_raw = Yaml::dump($compose, 10, 2);
|
||||
$service->save();
|
||||
foreach ($targets as $target) {
|
||||
$target['resource']->image = $this->withTag((string) $target['resource']->image, $target['tag']);
|
||||
$target['resource']->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
$restoreVariables = $this->setVariables($service, $variables);
|
||||
|
||||
try {
|
||||
RestartService::run($service, true);
|
||||
} catch (Throwable $e) {
|
||||
if ($targets !== []) {
|
||||
DB::transaction(function () use ($service, $oldCompose, $targets, $oldImages): void {
|
||||
$service->docker_compose_raw = $oldCompose;
|
||||
$service->save();
|
||||
foreach ($targets as $key => $target) {
|
||||
$target['resource']->image = $oldImages[$key];
|
||||
$target['resource']->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
$restoreVariables();
|
||||
if ($pinned) {
|
||||
ImageStartPin::forget($service);
|
||||
}
|
||||
report($e);
|
||||
|
||||
throw new ImageUpdateRefused('The restart failed. Coolify kept the old image.', previous: $e);
|
||||
}
|
||||
|
||||
return array_values(array_unique([
|
||||
...array_map(fn (array $target): string => (string) $target['resource']->getAttribute('uuid'), $targets),
|
||||
...$variableOwners,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Why Coolify cannot change the images of a Compose app at all. Null when it can.
|
||||
*/
|
||||
private static function composeRefusal(Application $application): ?string
|
||||
{
|
||||
if ($application->settings?->is_raw_compose_deployment_enabled) {
|
||||
return 'This app deploys its compose file as written. Change the version in the compose file, then redeploy.';
|
||||
}
|
||||
if ((int) $application->compose_parsing_version < 3) {
|
||||
return 'Change the version in the compose file of this app, then redeploy.';
|
||||
}
|
||||
// The command can build, pull, or start other images than the compose file names.
|
||||
if (filled($application->docker_compose_custom_build_command)) {
|
||||
return 'This app uses a custom build command, so Coolify cannot choose the image. Change the image in your compose file.';
|
||||
}
|
||||
if (filled($application->docker_compose_custom_start_command)) {
|
||||
return 'This app uses a custom start command, so Coolify cannot choose the image. Change the image in your compose file.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The services of the compose file that run the image in a way Coolify can change: an image line (or the override
|
||||
* that replaced it), or a variable that sets the image. Keyed by the service name, with the image it runs now.
|
||||
*
|
||||
* @param array<string, string> $services
|
||||
* @param array<string, array{from: string, to: string}> $overrides
|
||||
* @return array<string, array{variable: ?ComposeImageVariable, current: string}>
|
||||
*/
|
||||
private static function composeMatches(Application $application, array $services, array $overrides, string $imageRef): array
|
||||
{
|
||||
$matches = [];
|
||||
foreach ($services as $name => $image) {
|
||||
$variable = ComposeImageVariable::parse($image);
|
||||
if ($variable !== null) {
|
||||
$current = $variable->resolve(self::variableValue($application, $variable->key));
|
||||
} else {
|
||||
$current = ComposeImageVariable::hasVariable($image) ? null : ($overrides[$name]['to'] ?? $image);
|
||||
}
|
||||
|
||||
if ($current !== null && self::sameImage($current, $imageRef)) {
|
||||
$matches[$name] = ['variable' => $variable, 'current' => $current];
|
||||
}
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* A variable that the update changes must set only the image lines it changes. Anywhere else in the compose file
|
||||
* (environment, labels, a command, another image) the new value would change more than the image.
|
||||
*
|
||||
* @param array<string, string> $variableServices The variable per compose service whose image it sets.
|
||||
*/
|
||||
private static function refuseOtherVariableUses(string $rawCompose, array $variableServices): void
|
||||
{
|
||||
if ($variableServices === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$compose = parseDockerComposeYaml($rawCompose);
|
||||
} catch (Throwable) {
|
||||
$compose = null;
|
||||
}
|
||||
if (! is_array($compose)) {
|
||||
throw new ImageUpdateRefused('The compose file is not valid.');
|
||||
}
|
||||
|
||||
foreach (array_keys($variableServices) as $name) {
|
||||
if (is_array($compose['services'][$name] ?? null)) {
|
||||
unset($compose['services'][$name]['image']);
|
||||
}
|
||||
}
|
||||
|
||||
$rest = (string) json_encode($compose, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR);
|
||||
foreach (array_unique(array_values($variableServices)) as $key) {
|
||||
if (preg_match('/\$\{?'.preg_quote($key, '/').'(?![A-Za-z0-9_])/', $rest) === 1) {
|
||||
throw new ImageUpdateRefused("The compose file uses the variable {$key} in another place too. Change the version in the compose file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The images of the services of the compose file from Git, as written, keyed by the service name.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function composeServices(Application $application): array
|
||||
{
|
||||
try {
|
||||
$services = parseDockerComposeYaml((string) $application->docker_compose_raw)['services'] ?? null;
|
||||
} catch (Throwable) {
|
||||
$services = null;
|
||||
}
|
||||
if (! is_array($services)) {
|
||||
throw new ImageUpdateRefused('The compose file of this app is not valid.');
|
||||
}
|
||||
|
||||
$images = [];
|
||||
foreach ($services as $name => $service) {
|
||||
$image = is_array($service) ? ($service['image'] ?? null) : null;
|
||||
if (is_string($image) && trim($image) !== '') {
|
||||
$images[(string) $name] = trim($image);
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
private static function sameImage(string $a, string $b): bool
|
||||
{
|
||||
try {
|
||||
$first = ImageRef::parse(trim($a));
|
||||
$second = ImageRef::parse(trim($b));
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $first->digest === null && $second->digest === null
|
||||
&& $first->qualifiedName() === $second->qualifiedName()
|
||||
&& ($first->tag ?? 'latest') === ($second->tag ?? 'latest');
|
||||
}
|
||||
|
||||
/**
|
||||
* The value of a Coolify environment variable of the resource; null when it is not set.
|
||||
*/
|
||||
private static function variableValue(Application|Service $resource, string $key): ?string
|
||||
{
|
||||
$variable = $resource->environment_variables()->where('key', $key)->where('is_preview', false)->first();
|
||||
|
||||
return $variable instanceof EnvironmentVariable ? (string) $variable->value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $variables
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function addVariable(array $variables, string $key, string $value): array
|
||||
{
|
||||
if (isset($variables[$key]) && $variables[$key] !== $value) {
|
||||
throw new ImageUpdateRefused("The variable {$key} sets more than one of these images. Change the versions in the compose file.");
|
||||
}
|
||||
$variables[$key] = $value;
|
||||
|
||||
return $variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* A variable that also sets the image of another application or database that this update does not change would
|
||||
* change that image too.
|
||||
*
|
||||
* @param non-empty-list<ImageChange> $changes
|
||||
*/
|
||||
private function refuseSharedVariable(Service $service, string $key, array $changes): void
|
||||
{
|
||||
$changing = array_map(fn (array $change): string => $change['owner']->getMorphClass().':'.$change['owner']->getKey(), $changes);
|
||||
foreach (ResourceScans::owners($service) as $owner) {
|
||||
$image = ComposeImageVariable::parse((string) $owner->getAttribute('image'));
|
||||
if ($image !== null && $image->key === $key && ! in_array($owner->getMorphClass().':'.$owner->getKey(), $changing, true)) {
|
||||
throw new ImageUpdateRefused("The variable {$key} also sets the image of {$owner->getAttribute('name')}. Change the versions in the compose file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the environment variables of the resource, and returns what puts the old values back. A value that refers
|
||||
* to a shared variable or a secret is not replaced.
|
||||
*
|
||||
* @param array<string, string> $variables
|
||||
*/
|
||||
private function setVariables(Application|Service $resource, array $variables): callable
|
||||
{
|
||||
$old = [];
|
||||
foreach ($variables as $key => $value) {
|
||||
$variable = $resource->environment_variables()->where('key', $key)->where('is_preview', false)->first();
|
||||
if ($variable instanceof EnvironmentVariable && $variable->is_shown_once) {
|
||||
throw new ImageUpdateRefused("The variable {$key} is locked, so Coolify does not change it.");
|
||||
}
|
||||
if ($variable instanceof EnvironmentVariable && str_contains((string) $variable->value, '{{')) {
|
||||
throw new ImageUpdateRefused("The variable {$key} refers to another variable. Change the version there.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($variables as $key => $value) {
|
||||
$variable = $resource->environment_variables()->where('key', $key)->where('is_preview', false)->first();
|
||||
if ($variable instanceof EnvironmentVariable) {
|
||||
$old[$key] = ['variable' => $variable, 'value' => $variable->value, 'created' => false];
|
||||
$variable->value = $value;
|
||||
$variable->save();
|
||||
} else {
|
||||
$old[$key] = ['variable' => $resource->environment_variables()->create(['key' => $key, 'value' => $value, 'is_preview' => false]), 'value' => null, 'created' => true];
|
||||
}
|
||||
}
|
||||
|
||||
return function () use ($old): void {
|
||||
foreach ($old as $entry) {
|
||||
if ($entry['created']) {
|
||||
$entry['variable']->delete();
|
||||
} else {
|
||||
$entry['variable']->value = $entry['value'];
|
||||
$entry['variable']->save();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The applications and databases of a service that run the image.
|
||||
*
|
||||
* @return Collection<int, ServiceApplication|ServiceDatabase>
|
||||
*/
|
||||
public static function subResourcesUsing(Service $service, string $imageRef): Collection
|
||||
{
|
||||
try {
|
||||
$scanned = ImageRef::parse($imageRef);
|
||||
} catch (InvalidArgumentException) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return ResourceScans::owners($service)
|
||||
->filter(function (Model $subResource) use ($scanned): bool {
|
||||
try {
|
||||
$image = ImageRef::parse(trim((string) $subResource->getAttribute('image')));
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $image->digest === null && $scanned->digest === null
|
||||
&& $image->qualifiedName() === $scanned->qualifiedName()
|
||||
&& ($image->tag ?? 'latest') === ($scanned->tag ?? 'latest');
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* The compose file with the new image for each sub-resource. Refuses when the compose file names another image
|
||||
* for one of them, for example a variable, because then the image does not come from the field Coolify changes.
|
||||
*
|
||||
* @param array<string, array{resource: ServiceApplication|ServiceDatabase, tag: string}> $targets
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function composeWithImages(Service $service, array $targets): array
|
||||
{
|
||||
try {
|
||||
$compose = parseDockerComposeYaml((string) $service->docker_compose_raw);
|
||||
} catch (ParseException) {
|
||||
$compose = null;
|
||||
}
|
||||
if (! is_array($compose) || ! is_array($compose['services'] ?? null)) {
|
||||
throw new ImageUpdateRefused('The compose file of this service is not valid.');
|
||||
}
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$subResource = $target['resource'];
|
||||
$name = (string) $subResource->name;
|
||||
$configured = $compose['services'][$name]['image'] ?? null;
|
||||
if (! is_string($configured) || trim($configured) !== trim((string) $subResource->image)) {
|
||||
throw new ImageUpdateRefused("The compose file sets the image of {$name} in another way. Change it in the compose file.");
|
||||
}
|
||||
$compose['services'][$name]['image'] = $this->withTag((string) $subResource->image, $target['tag']);
|
||||
}
|
||||
|
||||
return $compose;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured image with another tag, keeping how the user wrote the name (`postgres`, `docker.io/library/postgres`).
|
||||
*/
|
||||
private function withTag(string $image, string $tag): string
|
||||
{
|
||||
$image = trim($image);
|
||||
$lastColon = strrpos($image, ':');
|
||||
$lastSlash = strrpos($image, '/');
|
||||
$name = $lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash) ? substr($image, 0, $lastColon) : $image;
|
||||
|
||||
return $name.':'.$tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Services\Dns\DnsResolver;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
|
||||
/**
|
||||
* Adds and removes public keys in ~/.ssh/authorized_keys of the SSH user. Both operations are safe to run again.
|
||||
*/
|
||||
class AuthorizedKeys
|
||||
{
|
||||
public static function canRunOn(Server $server): bool
|
||||
{
|
||||
return ! $server->isTransferredAway() && ! $server->skipServer();
|
||||
}
|
||||
|
||||
/** The exit code of add() when ~/.ssh/authorized_keys has no line with the old key. */
|
||||
public const OLD_KEY_NOT_FOUND = 3;
|
||||
|
||||
/**
|
||||
* Adds the new key with the options of every line of the old key. The old key stays until remove().
|
||||
* Throws a RuntimeException with the code OLD_KEY_NOT_FOUND when the file has no line with the old key.
|
||||
*/
|
||||
public static function add(Server $server, PrivateKey $privateKey, PrivateKey $oldKey): void
|
||||
{
|
||||
self::assertCanRunOn($server);
|
||||
self::runLocked($server, [
|
||||
'f="$HOME/.ssh/authorized_keys"',
|
||||
'tmp=$(mktemp "$f.cpk-XXXXXX")',
|
||||
...self::rotatedLinesCommands($privateKey, $oldKey, '"$tmp"', '{ echo "The old key is not in ~/.ssh/authorized_keys of the SSH user." >&2; exit '.self::OLD_KEY_NOT_FOUND.'; }'),
|
||||
// A last line without a final newline would be joined with the new line.
|
||||
'if [ -s "$f" ] && [ -n "$(tail -c1 "$f")" ]; then echo >> "$f"; fi',
|
||||
// Do not add an unrestricted fallback, including on retry.
|
||||
'while IFS= read -r entry; do grep -qxF -- "$entry" "$f" || printf \'%s\\n\' "$entry" >> "$f"; done < "$tmp"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes add() after the new key could not log in: removes only the exact lines that add() wrote.
|
||||
* Other lines stay, also lines that contain the new key. Returns false when another server record
|
||||
* for the same host and user uses the new key.
|
||||
*/
|
||||
public static function undoAdd(Server $server, PrivateKey $privateKey, PrivateKey $oldKey): bool
|
||||
{
|
||||
self::assertCanRunOn($server);
|
||||
if (self::isUsedOnSameHost($server, $privateKey->publicKeyBody(), $privateKey->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::runLocked($server, [
|
||||
'f="$HOME/.ssh/authorized_keys"',
|
||||
'if [ ! -f "$f" ]; then exit 0; fi',
|
||||
'tmp=$(mktemp "$f.cpk-XXXXXX")',
|
||||
'cp -p "$f" "$tmp"',
|
||||
// Without a line of the old key, add() wrote nothing.
|
||||
...self::rotatedLinesCommands($privateKey, $oldKey, '"$tmp.lines"', 'exit 0'),
|
||||
'rc=0',
|
||||
'grep -vxF -f "$tmp.lines" -- "$f" > "$tmp" || rc=$?',
|
||||
'if [ "$rc" -gt 1 ]; then exit 1; fi',
|
||||
'mv -f "$tmp" "$f"',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to $output the lines that add() writes: the new key with the options of each line of the old key.
|
||||
* Runs $notFound when the file has no line with the old key.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function rotatedLinesCommands(PrivateKey $privateKey, PrivateKey $oldKey, string $output, string $notFound): array
|
||||
{
|
||||
$line = escapeshellarg($privateKey->authorizedKeysLine());
|
||||
$oldBody = escapeshellarg($oldKey->publicKeyBody());
|
||||
// Tokenize outside quoted options; never interpret options as shell commands.
|
||||
$script = <<<'AWK'
|
||||
function token( start,c,quoted,escaped) {
|
||||
while (substr($0,pos,1) ~ /[ \t]/ && pos <= length($0)) pos++
|
||||
start=pos; quoted=0; escaped=0
|
||||
while (pos <= length($0)) {
|
||||
c=substr($0,pos,1)
|
||||
if (escaped) escaped=0
|
||||
else if (c == "\\" && quoted) escaped=1
|
||||
else if (c == "\"") quoted=!quoted
|
||||
else if (!quoted && c ~ /[ \t]/) break
|
||||
pos++
|
||||
}
|
||||
return substr($0,start,pos-start)
|
||||
}
|
||||
{
|
||||
pos=1
|
||||
first=token()
|
||||
if (first == "" || substr(first,1,1) == "#") next
|
||||
prefix=""
|
||||
if (first != type) { prefix=first " "; first=token() }
|
||||
data=token()
|
||||
if (first == type && data == key) { print prefix replacement; found=1 }
|
||||
}
|
||||
END { if (!found) exit 1 }
|
||||
AWK;
|
||||
|
||||
return [
|
||||
"old={$oldBody}",
|
||||
'type=${old%% *}; key=${old#* }',
|
||||
"awk -v type=\"\$type\" -v key=\"\$key\" -v replacement={$line} ".escapeshellarg($script)." \"\$f\" > {$output} || {$notFound}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every line with the public key, also lines that cloud-init or a cloud provider added.
|
||||
* Returns false when another server record for the same host and user still uses the key.
|
||||
*/
|
||||
public static function remove(Server $server, PrivateKey $privateKey): bool
|
||||
{
|
||||
return self::removePublicKey($server, $privateKey->publicKeyBody(), $privateKey->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like remove(), for a key whose record may be deleted already.
|
||||
*
|
||||
* @param string $publicKeyBody The key type and the base64 data, without a comment.
|
||||
*/
|
||||
public static function removePublicKey(Server $server, string $publicKeyBody, ?int $privateKeyId = null): bool
|
||||
{
|
||||
self::assertCanRunOn($server);
|
||||
if (self::isUsedOnSameHost($server, $publicKeyBody, $privateKeyId)) {
|
||||
return false;
|
||||
}
|
||||
$body = escapeshellarg($publicKeyBody);
|
||||
|
||||
self::runLocked($server, [
|
||||
'f="$HOME/.ssh/authorized_keys"',
|
||||
'if [ ! -f "$f" ]; then exit 0; fi',
|
||||
'tmp=$(mktemp "$f.cpk-XXXXXX")',
|
||||
// Preserve the existing owner and mode before replacing the file atomically.
|
||||
'cp -p "$f" "$tmp"',
|
||||
'rc=0',
|
||||
"grep -vF -- {$body} \"\$f\" > \"\$tmp\" || rc=\$?",
|
||||
'if [ "$rc" -gt 1 ]; then exit 1; fi',
|
||||
'mv -f "$tmp" "$f"',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The SHA256 fingerprint in the format of PrivateKey::$fingerprint, or null when the key cannot be read. */
|
||||
public static function fingerprint(string $publicKeyBody): ?string
|
||||
{
|
||||
try {
|
||||
return PublicKeyLoader::load($publicKeyBody)->getFingerprint('sha256');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All edits share a lock in the remote SSH home, also when server records use different aliases or keys.
|
||||
* mkdir is atomic and available on supported hosts without requiring flock.
|
||||
*/
|
||||
private static function runLocked(Server $server, array $commands): void
|
||||
{
|
||||
instant_remote_process([
|
||||
'if [ ! -d "$HOME/.ssh" ]; then mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"; fi',
|
||||
'lock="$HOME/.ssh/.coolify-authorized-keys.lock"',
|
||||
'attempt=0',
|
||||
// A lock older than 10 minutes is left from an edit that crashed. Edits take a few seconds.
|
||||
'find "$lock" -maxdepth 0 -type d -mmin +10 -exec rmdir {} \\; 2>/dev/null || true',
|
||||
'until mkdir "$lock" 2>/dev/null; do',
|
||||
'attempt=$((attempt + 1))',
|
||||
'if [ "$attempt" -ge 30 ]; then echo "Another authorized_keys edit is running; try again later." >&2; exit 1; fi',
|
||||
'sleep 1',
|
||||
'done',
|
||||
'tmp=""',
|
||||
"trap '[ -z \"\$tmp\" ] || rm -f \"\$tmp\" \"\$tmp.lines\"; rmdir \"\$lock\"' EXIT",
|
||||
"trap 'exit 1' HUP INT TERM",
|
||||
...$commands,
|
||||
], $server, no_sudo: true);
|
||||
}
|
||||
|
||||
private static function isUsedOnSameHost(Server $server, string $publicKeyBody, ?int $privateKeyId): bool
|
||||
{
|
||||
$fingerprint = self::fingerprint($publicKeyBody);
|
||||
// Other teams count only with the same key material: removing it would lock them out.
|
||||
// Keys saved before fingerprints existed have none stored, so compare their key material.
|
||||
$others = Server::query()
|
||||
->with('privateKey')
|
||||
->where('user', $server->user)
|
||||
->whereKeyNot($server->id)
|
||||
->where(fn ($query) => $query->when($privateKeyId, fn ($query) => $query->where('private_key_id', $privateKeyId))
|
||||
->orWhereHas('privateKey', fn ($keys) => $keys->where('fingerprint', $fingerprint)->orWhereNull('fingerprint')))
|
||||
->with('settings:id,server_id,ssh_host_key')
|
||||
->get(['id', 'ip', 'private_key_id'])
|
||||
->filter(fn (Server $other) => ($privateKeyId && $other->private_key_id === $privateKeyId)
|
||||
|| ($other->privateKey && $fingerprint
|
||||
&& ($other->privateKey->fingerprint ?: PrivateKey::generateFingerprint($other->privateKey->private_key)) === $fingerprint));
|
||||
$addresses = [];
|
||||
foreach ($others as $other) {
|
||||
$host = strtolower(rtrim(Server::normalizeIp($server->ip), '.'));
|
||||
$otherHost = strtolower(rtrim(Server::normalizeIp($other->ip), '.'));
|
||||
if ($host === $otherHost) {
|
||||
return true;
|
||||
}
|
||||
// Two addresses of one machine (for example a public and a Tailscale IP) show the same host key.
|
||||
// Without a pinned host key on both records, it is not known that these are different machines.
|
||||
$hostKeys = self::hostKeys($server);
|
||||
$otherHostKeys = self::hostKeys($other);
|
||||
if ($hostKeys === [] || $otherHostKeys === [] || array_intersect($hostKeys, $otherHostKeys) !== []) {
|
||||
return true;
|
||||
}
|
||||
$addresses[$host] ??= self::hostAddresses($host);
|
||||
$addresses[$otherHost] ??= self::hostAddresses($otherHost);
|
||||
// An unresolved hostname cannot prove that these are different machines. Keep the key.
|
||||
if ($addresses[$host] === [] || $addresses[$otherHost] === []
|
||||
|| array_intersect($addresses[$host], $addresses[$otherHost]) !== []) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return list<string> The pinned SSH host keys of the server, as "type base64" lines. */
|
||||
private static function hostKeys(Server $server): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
fn (string $line) => implode(' ', array_slice(preg_split('/\s+/', trim($line)), 0, 2)),
|
||||
preg_split('/\R/', (string) $server->settings?->ssh_host_key),
|
||||
)));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function hostAddresses(string $host): array
|
||||
{
|
||||
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
return [$host];
|
||||
}
|
||||
$resolver = trim(explode(',', (string) instanceSettings()->custom_dns_servers)[0]);
|
||||
if ($resolver === '') {
|
||||
return [];
|
||||
}
|
||||
$addresses = [];
|
||||
foreach ([DnsResolver::TYPE_A, DnsResolver::TYPE_AAAA] as $type) {
|
||||
try {
|
||||
$records = queryDnsServer($resolver, $host, $type, timeout: 2);
|
||||
if ($records === false) {
|
||||
return [];
|
||||
}
|
||||
foreach ($records as $record) {
|
||||
$addresses[] = Server::normalizeIp($record->getData());
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return $addresses;
|
||||
}
|
||||
|
||||
private static function assertCanRunOn(Server $server): void
|
||||
{
|
||||
if (! self::canRunOn($server)) {
|
||||
throw new \RuntimeException('Coolify does not run commands on this server.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,24 @@ class StartSentinel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker reads an env file line by line, so a line break in a value would add a variable.
|
||||
*
|
||||
* @param array<string, mixed> $environments
|
||||
*/
|
||||
public static function envFileContent(array $environments): string
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($environments as $key => $value) {
|
||||
if (preg_match('/[\r\n]/', (string) $value)) {
|
||||
throw new \RuntimeException("The Sentinel setting {$key} must not contain a line break.");
|
||||
}
|
||||
$lines[] = "{$key}={$value}";
|
||||
}
|
||||
|
||||
return implode("\n", $lines)."\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws LockTimeoutException when another start for the server does not finish in time
|
||||
*/
|
||||
@@ -141,7 +159,8 @@ class StartSentinel
|
||||
$image = $customImage;
|
||||
}
|
||||
}
|
||||
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
|
||||
$envFile = devHostDockerPath($server, base_configuration_dir().'/sentinel.env');
|
||||
$envFileContent = self::envFileContent($environments);
|
||||
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
|
||||
$trafficLogDirectory = self::trafficLogDirectory($server);
|
||||
$trafficMount = self::collectsTraffic($server)
|
||||
@@ -150,7 +169,7 @@ class StartSentinel
|
||||
$network = $server->isLocalhost() ? ' --network coolify' : '';
|
||||
// A userns-remap daemon refuses --pid host unless the container opts out of remapping.
|
||||
// On other daemons --userns host has no effect. https://docs.docker.com/engine/security/userns-remap/
|
||||
$dockerCommand = "docker run -d$network $dockerEnvironments --name coolify-sentinel --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--userns host --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
$dockerCommand = "docker run -d$network --env-file $envFile --name coolify-sentinel --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--userns host --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
|
||||
$server->sentinelHeartbeat(isReset: true);
|
||||
$server->forceFill(['sentinel_waiting_since' => now()])->save();
|
||||
@@ -159,7 +178,18 @@ class StartSentinel
|
||||
'docker rm -f coolify-sentinel || true',
|
||||
"mkdir -p $mountDir",
|
||||
...self::trafficLogPreparationCommands($server),
|
||||
$dockerCommand,
|
||||
'sh -ec '.escapeshellarg(implode("\n", [
|
||||
'trap '.escapeshellarg("rm -f $envFile").' EXIT',
|
||||
// The mode is set before the token is written, so it does not depend on the umask of the server.
|
||||
"rm -f $envFile",
|
||||
"touch $envFile",
|
||||
"chmod 600 $envFile",
|
||||
"echo '".base64_encode($envFileContent)."' | base64 -d | tee $envFile > /dev/null",
|
||||
$dockerCommand,
|
||||
// Docker keeps the environment in the container configuration, so restarts do not read the file again.
|
||||
"rm -f $envFile",
|
||||
'trap - EXIT',
|
||||
])),
|
||||
"chown -R 9999:root $mountDir",
|
||||
"chmod -R 700 $mountDir",
|
||||
], $server);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Exceptions\UpgradeAlreadyRunningException;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Services\AutoUpdateNotifier;
|
||||
use App\Services\CoolifyUpgradeStatus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Sleep;
|
||||
@@ -40,6 +42,7 @@ class UpdateCoolify
|
||||
if ($manual_update && filled($settings->update_custom_tag)) {
|
||||
$this->latestVersion = $settings->update_custom_tag;
|
||||
$this->backupDatabase();
|
||||
$this->ensureNoUpgradeIsRunning();
|
||||
$this->update(registryUrl: 'ghcr.io');
|
||||
}
|
||||
|
||||
@@ -127,7 +130,15 @@ class UpdateCoolify
|
||||
}
|
||||
|
||||
$this->backupDatabase();
|
||||
// Check the lock before the attempt is saved, so a running upgrade keeps its own saved attempt.
|
||||
$this->ensureNoUpgradeIsRunning();
|
||||
if (! $manual_update) {
|
||||
AutoUpdateNotifier::saveAttempt((string) $this->latestVersion);
|
||||
}
|
||||
$this->update();
|
||||
if (! $manual_update) {
|
||||
AutoUpdateNotifier::started((string) $this->currentVersion, (string) $this->latestVersion);
|
||||
}
|
||||
$settings->new_version_available = false;
|
||||
$settings->save();
|
||||
}
|
||||
@@ -151,8 +162,6 @@ class UpdateCoolify
|
||||
|
||||
private function update(?string $registryUrl = null)
|
||||
{
|
||||
$this->ensureNoUpgradeIsRunning();
|
||||
|
||||
$latestHelperImageVersion = getHelperVersion();
|
||||
$upgradeScriptUrl = $this->upgradeScriptUrl ?? config('constants.coolify.upgrade_script_url');
|
||||
$registryUrl ??= coolifyRegistryUrl();
|
||||
@@ -175,10 +184,7 @@ class UpdateCoolify
|
||||
'target_version' => $this->latestVersion,
|
||||
'status' => $status,
|
||||
]);
|
||||
throw new \Exception(
|
||||
'Another Coolify upgrade is already running. Wait for it to finish. '.
|
||||
'If it has stopped, you can upgrade again '.CoolifyUpgradeStatus::RUNNING_LOCK_EXPIRES_AFTER_MINUTES.' minutes after its last status update.'
|
||||
);
|
||||
throw new UpgradeAlreadyRunningException;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace App\Actions\Service;
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Events\ServiceStartFinished;
|
||||
use App\Jobs\NotifyResourceStoppedJob;
|
||||
use App\Jobs\ScanResourceImagesJob;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Services\ImageScan\ImageStartPin;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
@@ -46,6 +48,9 @@ class DeployServiceApplication
|
||||
$commands->push(serverDockerConfigPrefix($service->server)."docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName} < /dev/null");
|
||||
}
|
||||
|
||||
// After the pull: the scanned build of a "Pull latest" security update keeps the tag of the compose file.
|
||||
$commands->push(...ImageStartPin::commands(ImageStartPin::take($service), serverDockerConfigPrefix($service->server)));
|
||||
|
||||
if ($service->networks()->count() > 0) {
|
||||
$commands->push('echo Creating Docker network.');
|
||||
$commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}");
|
||||
@@ -76,6 +81,9 @@ class DeployServiceApplication
|
||||
|
||||
$commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true");
|
||||
|
||||
return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: ServiceStartFinished::dataFor($service), queue: deployment_queue());
|
||||
$activity = remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: ServiceStartFinished::dataFor($service), queue: deployment_queue());
|
||||
ScanResourceImagesJob::dispatchAfterStart($service);
|
||||
|
||||
return $activity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Service;
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Events\ServiceStartFinished;
|
||||
use App\Jobs\NotifyResourceStoppedJob;
|
||||
use App\Jobs\ScanResourceImagesJob;
|
||||
use App\Jobs\ServerFilesFromServerJob;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Services\ImageScan\ImageStartPin;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
@@ -60,6 +62,8 @@ class StartService
|
||||
$commands[] = "echo 'Pulling images.'";
|
||||
$commands[] = "{$compose} pull < /dev/null";
|
||||
}
|
||||
// After the pull: the scanned build of a "Pull latest" security update keeps the tag of the compose file.
|
||||
$commands = array_merge($commands, ImageStartPin::commands(ImageStartPin::take($service), serverDockerConfigPrefix($service->server)));
|
||||
if ($service->networks()->count() > 0) {
|
||||
$commands[] = "echo 'Creating Docker network.'";
|
||||
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
|
||||
@@ -91,7 +95,10 @@ class StartService
|
||||
}
|
||||
}
|
||||
|
||||
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: ServiceStartFinished::dataFor($service), queue: deployment_queue());
|
||||
$activity = remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: ServiceStartFinished::dataFor($service), queue: deployment_queue());
|
||||
ScanResourceImagesJob::dispatchAfterStart($service);
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace App\Actions\Shared;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\Dns\CloudflareDnsProvider;
|
||||
use App\Services\Dns\DnsResolver;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class CheckDomainDns
|
||||
@@ -66,7 +66,7 @@ class CheckDomainDns
|
||||
return $this->result('ok', 'DNS looks correct.', $expectedIp);
|
||||
}
|
||||
|
||||
$type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
|
||||
$type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DnsResolver::TYPE_AAAA : DnsResolver::TYPE_A;
|
||||
$receivedAddressRecord = false;
|
||||
|
||||
foreach ($dnsServers as $dnsServer) {
|
||||
@@ -138,8 +138,8 @@ class CheckDomainDns
|
||||
*/
|
||||
protected function resolveWithSystemDns(string $host, string $type): array
|
||||
{
|
||||
$recordType = $type === DNSTypes::NAME_AAAA ? DNS_AAAA : DNS_A;
|
||||
$addressKey = $type === DNSTypes::NAME_AAAA ? 'ipv6' : 'ip';
|
||||
$recordType = $type === DnsResolver::TYPE_AAAA ? DNS_AAAA : DNS_A;
|
||||
$addressKey = $type === DnsResolver::TYPE_AAAA ? 'ipv6' : 'ip';
|
||||
|
||||
try {
|
||||
$records = @dns_get_record($host, $recordType);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Enums\ActivityTypes;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\SyncGithubDeploymentJob;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -15,6 +16,7 @@ use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\User;
|
||||
use App\Services\AutoUpdateNotifier;
|
||||
use App\Support\ResourceStartActivity;
|
||||
use App\Support\RootSetupWindow;
|
||||
use Carbon\Carbon;
|
||||
@@ -74,6 +76,12 @@ class Init extends Command
|
||||
}
|
||||
get_public_ips();
|
||||
|
||||
try {
|
||||
AutoUpdateNotifier::checkResult();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not check the automatic update result: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
$this->replaceSlashInEnvironmentName();
|
||||
$this->restoreCoolifyDbBackup();
|
||||
@@ -99,16 +107,24 @@ class Init extends Command
|
||||
echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
|
||||
}
|
||||
try {
|
||||
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
|
||||
$stuckDeployments = ApplicationDeploymentQueue::whereIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])->update([
|
||||
])->get(['id', 'application_id', 'parent_deployment_uuid']);
|
||||
$updatedCount = ApplicationDeploymentQueue::whereKey($stuckDeployments->modelKeys())->update([
|
||||
'status' => ApplicationDeploymentStatus::FAILED->value,
|
||||
]);
|
||||
|
||||
if ($updatedCount > 0) {
|
||||
echo "Marked {$updatedCount} stuck deployments as failed\n";
|
||||
}
|
||||
|
||||
// The query update above fires no model event, so GitHub would show them in progress forever.
|
||||
try {
|
||||
$stuckDeployments->each(fn (ApplicationDeploymentQueue $deployment) => SyncGithubDeploymentJob::dispatchFor($deployment));
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not queue the GitHub deployment sync of stuck deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Jobs\CheckMissingVolumeBackupsJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||
use App\Jobs\CleanupStaleMultiplexedConnections;
|
||||
use App\Jobs\CleanupUnusedSshKeys;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\ReconcileGithubRunnersJob;
|
||||
@@ -48,6 +49,11 @@ class Kernel extends ConsoleKernel
|
||||
$this->instanceTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
// Files are local to each cloud node; do not dispatch to the shared queue.
|
||||
$this->scheduleInstance->call(fn () => app(CleanupUnusedSshKeys::class)->handle())
|
||||
->name('cleanup:ssh-keys')
|
||||
->hourly();
|
||||
|
||||
$this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
|
||||
->name('cleanup:ssh-mux')
|
||||
->hourly()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* The protocol of a backup storage (`s3_storages.type`).
|
||||
*/
|
||||
enum BackupStorageType: string
|
||||
{
|
||||
case S3 = 's3';
|
||||
case Sftp = 'sftp';
|
||||
case Ftps = 'ftps';
|
||||
case Webdav = 'webdav';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::S3 => 'S3',
|
||||
self::Sftp => 'SFTP',
|
||||
self::Ftps => 'FTPS',
|
||||
self::Webdav => 'WebDAV',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL schemes that the endpoint of this type can use.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function endpointSchemes(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::S3, self::Webdav => ['https', 'http'],
|
||||
self::Sftp => ['sftp'],
|
||||
self::Ftps => ['ftps'],
|
||||
};
|
||||
}
|
||||
|
||||
public function defaultPort(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::S3, self::Webdav => null,
|
||||
self::Sftp => 22,
|
||||
self::Ftps => 21,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{value: string, label: string}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(fn (self $type): array => ['value' => $type->value, 'label' => $type->label()], self::cases());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ImageScanErrorCode: string
|
||||
{
|
||||
case DiskSpace = 'disk_space';
|
||||
case DbTooOld = 'db_too_old';
|
||||
case DbMissing = 'db_missing';
|
||||
case Timeout = 'timeout';
|
||||
case OutOfMemory = 'out_of_memory';
|
||||
case LockBusy = 'lock_busy';
|
||||
case RegistryAuth = 'registry_auth';
|
||||
case BuildServerOff = 'build_server_off';
|
||||
case ScanningOff = 'scanning_off';
|
||||
case CustomCommand = 'custom_command';
|
||||
case ImageMissing = 'image_missing';
|
||||
case PullFailed = 'pull_failed';
|
||||
case Truncated = 'truncated';
|
||||
case Unknown = 'unknown';
|
||||
|
||||
/**
|
||||
* Fixed user-facing message. Raw command output is never shown.
|
||||
*
|
||||
* @param array{server?: string, n?: int|string} $context
|
||||
*/
|
||||
public function message(array $context = []): string
|
||||
{
|
||||
$server = (string) ($context['server'] ?? 'the server');
|
||||
$n = (string) ($context['n'] ?? '?');
|
||||
|
||||
return match ($this) {
|
||||
self::DiskSpace => "Not enough disk space on {$server}: {$n} GB needed.",
|
||||
self::DbTooOld => "The vulnerability database is {$n} days old. {$server} cannot reach grype.anchore.io.",
|
||||
self::DbMissing => "The vulnerability database is not on {$server} yet. Wait for the first update to finish or check that {$server} can reach grype.anchore.io.",
|
||||
self::Timeout => 'The scan took too long and was stopped.',
|
||||
self::OutOfMemory => 'The scanner needed more than 1 GB of memory for this image and was stopped.',
|
||||
self::LockBusy => "Another scan is still running on {$server}.",
|
||||
self::RegistryAuth => "Coolify cannot read this image from its registry. Log in to the registry on {$server}.",
|
||||
self::BuildServerOff => 'Image scanning is off on the build server.',
|
||||
self::ScanningOff => "Turn on image scanning on {$server}.",
|
||||
self::CustomCommand => 'Coolify cannot check the image because this application uses a custom build or start command.',
|
||||
self::ImageMissing => "The image is not on {$server}. Click Scan now to pull and scan it. An image that Coolify builds needs a new deploy.",
|
||||
self::PullFailed => "Coolify could not pull the image to {$server}. Check the image name, and log in to its registry on {$server} if it is private.",
|
||||
self::Truncated => 'The scan result was too large to read completely.',
|
||||
self::Unknown => 'The scan could not run.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/** The fix states of the findings lists; the values are the keys of `Vulnerabilities::FIX_STATES`. */
|
||||
enum ImageScanFixState: string
|
||||
{
|
||||
case Fixed = 'fixed';
|
||||
case NoFix = 'no_fix';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* "Block deploys" (spec 15.8): which problems of a new image stop a deploy.
|
||||
*/
|
||||
enum ImageScanGateMode: string
|
||||
{
|
||||
case Off = 'off';
|
||||
case On = 'on';
|
||||
case Strict = 'strict';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Off => 'Off',
|
||||
self::On => 'On',
|
||||
self::Strict => 'Strict',
|
||||
};
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Off => null,
|
||||
self::On => 'Blocks a deploy when the new image adds a problem that attackers are using or are likely to use.',
|
||||
self::Strict => 'Also blocks critical, high, and counted medium problems that have a fix, and operating systems that no longer get security fixes.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ImageScanOnError: string
|
||||
{
|
||||
case Deploy = 'deploy';
|
||||
case Block = 'block';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Deploy => 'Deploy anyway',
|
||||
self::Block => 'Block the deploy',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ImageScanStatus: string
|
||||
{
|
||||
case Queued = 'queued';
|
||||
case Running = 'running';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* What fixes a relevant finding of a scanned image (spec 15.5). The cases are in the order the actions are listed.
|
||||
*/
|
||||
enum SecurityActionType: string
|
||||
{
|
||||
case UpdateImage = 'update_image';
|
||||
case PullLatest = 'pull_latest';
|
||||
case ChangeBase = 'change_base';
|
||||
case Rebuild = 'rebuild';
|
||||
case UpgradeInDockerfile = 'upgrade_in_dockerfile';
|
||||
case UpgradePackage = 'upgrade_package';
|
||||
case RebuildInCi = 'rebuild_in_ci';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* The security status of a resource or an image (spec 15.6). The cases are in the order the rules are applied.
|
||||
*/
|
||||
enum SecurityStatusType: string
|
||||
{
|
||||
case NotChecked = 'not_checked';
|
||||
case CheckNeeded = 'check_needed';
|
||||
case UpdateNow = 'update_now';
|
||||
case UpdateAvailable = 'update_available';
|
||||
case NoFix = 'no_fix';
|
||||
case Ok = 'ok';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Traits\BroadcastsToTeam;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ImageScanUpdated implements ShouldBroadcast
|
||||
{
|
||||
use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param bool $final False for a progress update of a running scan; pages that show only results ignore it.
|
||||
*/
|
||||
public function __construct(?int $teamId, public string $resourceUuid, public string $scanUuid, public bool $final = true)
|
||||
{
|
||||
$this->teamId = $teamId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{resourceUuid: string, scanUuid: string, final: bool}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return ['resourceUuid' => $this->resourceUuid, 'scanUuid' => $this->scanUuid, 'final' => $this->final];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Services\CoolifyUpgradeStatus;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* An upgrade was not started because another upgrade script is still running.
|
||||
* This is not a failure of the running upgrade.
|
||||
*/
|
||||
class UpgradeAlreadyRunningException extends Exception
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(
|
||||
'Another Coolify upgrade is already running. Wait for it to finish. '.
|
||||
'If it has stopped, you can upgrade again '.CoolifyUpgradeStatus::RUNNING_LOCK_EXPIRES_AFTER_MINUTES.' minutes after its last status update.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Jobs\ServerConnectionCheckJob;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Contracts\Process\ProcessResult;
|
||||
use Illuminate\Process\Exceptions\ProcessTimedOutException;
|
||||
@@ -13,9 +15,33 @@ use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SshMultiplexingHelper
|
||||
{
|
||||
/** The host key scan uses the connection timeout of the server, but it runs while Coolify builds an SSH command, so it stops after this time. */
|
||||
private const HOST_KEY_SCAN_MAX_CONNECT_TIMEOUT_SECONDS = 30;
|
||||
|
||||
/** After a failed scan (server offline), commands and the connection check do not scan again for this time. */
|
||||
private const HOST_KEY_SCAN_RETRY_SECONDS = 600;
|
||||
|
||||
/**
|
||||
* The warning that OpenSSH prints at the start of its own stderr when the server sends a host key that is not
|
||||
* the pinned key: the banner, the fingerprint, the known_hosts lines, and the "is disabled to avoid
|
||||
* man-in-the-middle attacks" lines of the login methods that it turns off.
|
||||
*/
|
||||
private const HOST_KEY_CHANGED_WARNING_PATTERN = '/\A@+\r?\n@\s+WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\s+@\r?\n@+\r?\n'
|
||||
.'(?:[^@\r\n].*\r?\n){0,20}?Offending .+ key in .+\r?\n(?: remove with:\r?\n ssh-keygen .+\r?\n)?'
|
||||
.'(?:(?:.+ is disabled to avoid man-in-the-middle attacks\.|UpdateHostkeys is disabled because the host key is not trusted\.)\r?\n)*/';
|
||||
|
||||
/** Stderr that starts like the warning is held at most this long, so other output is never held without limit. */
|
||||
private const HOST_KEY_CHANGED_WARNING_MAX_BYTES = 16384;
|
||||
|
||||
/** The unknown SSH_HOST_KEY_CHECKING value that this process already checked. */
|
||||
private static mixed $checkedUnknownHostKeyCheckingMode = null;
|
||||
|
||||
public const HOST_KEY_CHANGED_MESSAGE = 'The SSH host key of this server changed, so Coolify did not connect. An admin must check the new key on the Security page of the server.';
|
||||
|
||||
public static function serverSshConfiguration(Server $server): array
|
||||
{
|
||||
$privateKey = PrivateKey::where('team_id', $server->team_id)->findOrFail($server->private_key_id);
|
||||
@@ -105,9 +131,10 @@ class SshMultiplexingHelper
|
||||
}
|
||||
|
||||
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
|
||||
$establishCommand .= self::escapedUserAtHost($server).' true';
|
||||
$establishCommand .= '-- '.self::escapedUserAtHost($server).' true';
|
||||
|
||||
$establishProcess = Process::run($establishCommand);
|
||||
self::noticeHostKeyMismatch($server, $establishProcess);
|
||||
if ($establishProcess->exitCode() !== 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -196,10 +223,10 @@ class SshMultiplexingHelper
|
||||
|
||||
// Upload: local source -> remote dest
|
||||
if ($server->isIpv6()) {
|
||||
return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
|
||||
return $scpCommand.'-- '.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||
return $scpCommand.'-- '.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,10 +264,10 @@ class SshMultiplexingHelper
|
||||
|
||||
// Download: remote source -> local dest
|
||||
if ($server->isIpv6()) {
|
||||
return $scpCommand.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
|
||||
return $scpCommand.'-- '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
|
||||
}
|
||||
|
||||
return $scpCommand.self::escapedUserAtHost($server).':'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
|
||||
return $scpCommand.'-- '.self::escapedUserAtHost($server).':'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
|
||||
}
|
||||
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
@@ -251,7 +278,7 @@ class SshMultiplexingHelper
|
||||
$command = str_replace($delimiter, '', $command);
|
||||
$remoteShellCommand = self::remoteShellCommand();
|
||||
|
||||
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
|
||||
return $sshCommand.'-- '.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
|
||||
.$command.PHP_EOL
|
||||
.$delimiter;
|
||||
}
|
||||
@@ -283,7 +310,7 @@ class SshMultiplexingHelper
|
||||
public static function generateSshStdinCommand(Server $server, string $remoteCommand, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
{
|
||||
return self::sshCommandPrefix($server, $disableMultiplexing, $commandTimeout)
|
||||
.self::escapedUserAtHost($server).' '.escapeshellarg($remoteCommand);
|
||||
.'-- '.self::escapedUserAtHost($server).' '.escapeshellarg($remoteCommand);
|
||||
}
|
||||
|
||||
private static function sshCommandPrefix(Server $server, bool $disableMultiplexing, ?int $commandTimeout): string
|
||||
@@ -340,11 +367,14 @@ class SshMultiplexingHelper
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$healthCommand = 'ssh -o ControlMaster=auto -o ControlPath='.escapeshellarg($muxSocket).' ';
|
||||
// A real round trip through the master. If the master is gone, ssh connects directly,
|
||||
// so it needs the same options as every other connection. ControlMaster=no keeps it from becoming a master.
|
||||
$healthCommand = 'ssh -o ControlMaster=no -o ControlPath='.escapeshellarg($muxSocket).' ';
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
||||
$healthCommand .= self::getCommonSshOptions($server, $sshConfig['sshKeyLocation'], self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
|
||||
$healthCommand .= '-- '.self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
||||
|
||||
// The health check goes over the network, so a slow link gets at least the server's connection timeout.
|
||||
$timeout = max((int) config('constants.ssh.mux_health_check_timeout'), self::getConnectionTimeout($server));
|
||||
@@ -398,7 +428,7 @@ class SshMultiplexingHelper
|
||||
public static function runMuxSocketCommand(string $muxSocket, string $operation): ?ProcessResult
|
||||
{
|
||||
return self::runWithTimeout(
|
||||
"ssh -O {$operation} -o ControlPath=".escapeshellarg($muxSocket).' localhost',
|
||||
"ssh -F /dev/null -O {$operation} -o ControlPath=".escapeshellarg($muxSocket).' localhost',
|
||||
(int) config('constants.ssh.mux_health_check_timeout'),
|
||||
);
|
||||
}
|
||||
@@ -436,12 +466,12 @@ class SshMultiplexingHelper
|
||||
|
||||
private static function muxControlCommand(Server $server, string $operation): string
|
||||
{
|
||||
$command = "ssh -O {$operation} -o ControlPath=".escapeshellarg(self::muxSocket($server)).' ';
|
||||
$command = "ssh -F /dev/null -O {$operation} -o ControlPath=".escapeshellarg(self::muxSocket($server)).' ';
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
return $command.self::escapedUserAtHost($server);
|
||||
return $command.'-- '.self::escapedUserAtHost($server);
|
||||
}
|
||||
|
||||
private static function multiplexingOptions(Server $server): string
|
||||
@@ -458,6 +488,12 @@ class SshMultiplexingHelper
|
||||
|
||||
private static function escapedUserAtHost(Server $server): string
|
||||
{
|
||||
// ssh and scp read an argument that starts with "-" as an option, also after escaping.
|
||||
// Validation rejects such values; this also stops rows that were saved before it did.
|
||||
if (str_starts_with((string) $server->user, '-') || str_starts_with((string) $server->ip, '-')) {
|
||||
throw new \RuntimeException('The SSH user and the server IP must not start with "-".');
|
||||
}
|
||||
|
||||
return escapeshellarg($server->user).'@'.escapeshellarg($server->ip);
|
||||
}
|
||||
|
||||
@@ -499,15 +535,18 @@ class SshMultiplexingHelper
|
||||
]);
|
||||
}
|
||||
}
|
||||
self::giveToSshDirectoryOwner($keyLocation, $keyLocation.'.lock');
|
||||
}
|
||||
|
||||
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
|
||||
{
|
||||
$options = '-i '.escapeshellarg($sshKeyLocation).' '
|
||||
.'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null '
|
||||
.self::hostKeyOptions($server)
|
||||
.'-o PasswordAuthentication=no '
|
||||
."-o ConnectTimeout=$connectionTimeout "
|
||||
."-o ServerAliveInterval=$serverInterval "
|
||||
.'-F /dev/null -o IdentitiesOnly=yes '
|
||||
.'-o ClearAllForwardings=yes -o ForwardAgent=no '
|
||||
.'-o RequestTTY=no '
|
||||
.'-o LogLevel=ERROR ';
|
||||
|
||||
@@ -517,4 +556,348 @@ class SshMultiplexingHelper
|
||||
|
||||
return $options.'-p '.escapeshellarg((string) $server->port).' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* warn: connect also when the host key changed, and report it. enforce: refuse a changed host key. off: no check.
|
||||
*/
|
||||
public static function hostKeyCheckingMode(Server $server): string
|
||||
{
|
||||
$mode = config('constants.ssh.host_key_checking');
|
||||
if (! in_array($mode, ['warn', 'enforce', 'off'], true)) {
|
||||
self::warnAboutUnknownHostKeyCheckingMode($mode);
|
||||
}
|
||||
if ($mode === 'off' || data_get($server, 'settings.is_ssh_host_key_check_disabled')) {
|
||||
return 'off';
|
||||
}
|
||||
|
||||
return $mode === 'enforce' ? 'enforce' : 'warn';
|
||||
}
|
||||
|
||||
/**
|
||||
* A mistyped SSH_HOST_KEY_CHECKING value falls back to warn. Log it once a day, not for every command.
|
||||
*/
|
||||
private static function warnAboutUnknownHostKeyCheckingMode(mixed $mode): void
|
||||
{
|
||||
if (self::$checkedUnknownHostKeyCheckingMode === $mode) {
|
||||
return;
|
||||
}
|
||||
self::$checkedUnknownHostKeyCheckingMode = $mode;
|
||||
if (Cache::add('ssh_host_key_checking_unknown_warned_'.hash('sha256', (string) $mode), true, now()->addDay())) {
|
||||
Log::warning('Unknown SSH_HOST_KEY_CHECKING value, Coolify uses warn. Use warn, enforce, or off.', ['value' => $mode]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when ssh reports that the server sent a host key that does not match the pinned key.
|
||||
*/
|
||||
public static function isHostKeyMismatch(string $errorOutput): bool
|
||||
{
|
||||
return str_contains($errorOutput, 'REMOTE HOST IDENTIFICATION HAS CHANGED')
|
||||
|| str_contains($errorOutput, 'Host key verification failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* When an SSH process reports a changed host key, check the connection once. The check saves the new key
|
||||
* as pending and notifies the team, also for a server that the scheduled connection check skips (Sentinel).
|
||||
* The message must name the host key alias or the known_hosts file of the server: a program on the server
|
||||
* (git in a deployment) can print the same text for its own SSH connection. Without strict checking (warn
|
||||
* mode), ssh names only the known_hosts file.
|
||||
*/
|
||||
public static function noticeHostKeyMismatch(Server $server, ProcessResult $result): void
|
||||
{
|
||||
$errorOutput = $result->errorOutput();
|
||||
$namesThisServer = str_contains($errorOutput, self::hostKeyAlias($server))
|
||||
|| str_contains($errorOutput, '/known_hosts_'.$server->uuid.'_');
|
||||
if (! self::isHostKeyMismatch($errorOutput) || ! $namesThisServer
|
||||
|| ! Cache::add('ssh_host_key_mismatch_noticed_'.$server->uuid, true, 600)) {
|
||||
return;
|
||||
}
|
||||
ServerConnectionCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the changed host key warning of OpenSSH from the start of the stderr of an SSH process, for logs and
|
||||
* saved output. Detect a mismatch with the raw output (isHostKeyMismatch()). A warning later in the output
|
||||
* is from a program on the server (git) and stays.
|
||||
*/
|
||||
public static function stripHostKeyChangedWarning(string $output): string
|
||||
{
|
||||
return preg_replace(self::HOST_KEY_CHANGED_WARNING_PATTERN, '', $output, 1) ?? $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter streamed stderr of an SSH process: hold the first chunks while they can still be the changed host key
|
||||
* warning, then return them without it. Start with $held = ''; it becomes null when the start is passed.
|
||||
* Pass $final when the warning must be over (stdout arrived or the process ended).
|
||||
*/
|
||||
public static function filterHostKeyChangedWarning(?string &$held, string $chunk, bool $final = false): string
|
||||
{
|
||||
if ($held === null) {
|
||||
return $chunk;
|
||||
}
|
||||
$held .= $chunk;
|
||||
if (! $final && self::mayBeHostKeyChangedWarning($held)) {
|
||||
return '';
|
||||
}
|
||||
$output = self::stripHostKeyChangedWarning($held);
|
||||
$held = null;
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
private static function mayBeHostKeyChangedWarning(string $output): bool
|
||||
{
|
||||
if ($output === '') {
|
||||
return true;
|
||||
}
|
||||
if (! str_starts_with($output, '@') || strlen($output) > self::HOST_KEY_CHANGED_WARNING_MAX_BYTES) {
|
||||
return false;
|
||||
}
|
||||
if (! preg_match(self::HOST_KEY_CHANGED_WARNING_PATTERN, $output, $matches)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The rest of the warning can follow until a complete line arrives that is not part of it.
|
||||
return ! preg_match('/\A(?! remove with:).*\n/', substr($output, strlen($matches[0])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the host key that the server sends now. The probe has every login method off, so it offers no key
|
||||
* and runs no command: ssh saves the host key during the key exchange, then the login fails by design.
|
||||
*/
|
||||
public static function scanHostKey(Server $server): ?string
|
||||
{
|
||||
return self::readHostKey($server)['key'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: ?string, error: string}
|
||||
*/
|
||||
private static function readHostKey(Server $server): array
|
||||
{
|
||||
$connectTimeout = self::hostKeyScanConnectTimeout($server);
|
||||
$directory = self::knownHostsDirectory();
|
||||
File::ensureDirectoryExists($directory, 0700);
|
||||
self::giveToSshDirectoryOwner($directory);
|
||||
$scanFile = $directory.'/scan_'.$server->uuid.'_'.Str::random(8);
|
||||
|
||||
$command = 'ssh ';
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
|
||||
}
|
||||
$command .= '-F /dev/null -o HostKeyAlias='.escapeshellarg(self::hostKeyAlias($server)).' '
|
||||
.'-o UserKnownHostsFile='.escapeshellarg($scanFile).' -o GlobalKnownHostsFile=/dev/null '
|
||||
.'-o StrictHostKeyChecking=accept-new -o BatchMode=yes '
|
||||
.'-o PubkeyAuthentication=no -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no '
|
||||
.'-o HostbasedAuthentication=no '
|
||||
.'-o ConnectTimeout='.$connectTimeout.' -o LogLevel=ERROR '
|
||||
.'-p '.escapeshellarg((string) $server->port).' '
|
||||
.'-- '.self::escapedUserAtHost($server).' true';
|
||||
|
||||
try {
|
||||
$result = self::runWithTimeout($command, self::hostKeyScanProcessTimeout($server));
|
||||
|
||||
return [
|
||||
'key' => ServerSetting::normalizeSshHostKeys(@file_get_contents($scanFile) ?: null),
|
||||
'error' => $result === null ? 'The connection timed out.' : Str::limit(trim($result->errorOutput()), 300),
|
||||
];
|
||||
} finally {
|
||||
File::delete([$scanFile, $scanFile.'.old']);
|
||||
}
|
||||
}
|
||||
|
||||
/** The connection timeout of the server, like the other SSH commands use, capped for the scan. */
|
||||
private static function hostKeyScanConnectTimeout(Server $server): int
|
||||
{
|
||||
return min(self::getConnectionTimeout($server), self::HOST_KEY_SCAN_MAX_CONNECT_TIMEOUT_SECONDS);
|
||||
}
|
||||
|
||||
private static function hostKeyScanProcessTimeout(Server $server): int
|
||||
{
|
||||
return self::hostKeyScanConnectTimeout($server) + 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the host key that the server sends on the first connection (trust on first use).
|
||||
* Nothing scans again within HOST_KEY_SCAN_RETRY_SECONDS of a failed scan. Commands also do not scan a server
|
||||
* that is unreachable, so they do not wait for the scan timeout. The connection check passes
|
||||
* $evenIfUnreachable, so the server learns its key when it is back. In enforce mode the check then fails with
|
||||
* the scan error until the next scan.
|
||||
*/
|
||||
public static function learnHostKey(Server $server, bool $evenIfUnreachable = false): void
|
||||
{
|
||||
if (self::hostKeyCheckingMode($server) === 'off' || filled($server->settings->ssh_host_key) || ! $server->settings->exists) {
|
||||
return;
|
||||
}
|
||||
if (Cache::has(self::hostKeyScanFailedCacheKey($server)) || (! $evenIfUnreachable && $server->unreachable_count > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$learn = function () use ($server) {
|
||||
if (self::loadPinnedHostKey($server)) {
|
||||
return;
|
||||
}
|
||||
$scan = self::readHostKey($server);
|
||||
if ($scan['key'] === null) {
|
||||
Cache::put(self::hostKeyScanFailedCacheKey($server), $scan['error'], self::HOST_KEY_SCAN_RETRY_SECONDS);
|
||||
|
||||
return;
|
||||
}
|
||||
$pin = [
|
||||
'ssh_host_key' => $scan['key'],
|
||||
// A server that was never usable is new. A usable server learns its key after the upgrade.
|
||||
'ssh_host_key_source' => $server->settings->is_usable ? 'upgrade' : 'tofu',
|
||||
'ssh_host_key_trusted_at' => now(),
|
||||
'ssh_host_key_pending' => null,
|
||||
];
|
||||
// Write only the pin, so that unsaved settings changes of the caller stay unsaved.
|
||||
$pinned = ServerSetting::query()->whereKey($server->settings->getKey())->whereNull('ssh_host_key')->update($pin);
|
||||
if ($pinned === 0) {
|
||||
self::loadPinnedHostKey($server);
|
||||
|
||||
return;
|
||||
}
|
||||
$server->settings->forceFill($pin)->syncOriginalAttributes(array_keys($pin));
|
||||
// An open multiplexed connection was made without the pinned key.
|
||||
self::removeMuxFile($server);
|
||||
};
|
||||
|
||||
$lock = Cache::lock('ssh_host_key_learn_'.$server->uuid, self::HOST_KEY_SCAN_MAX_CONNECT_TIMEOUT_SECONDS + 25);
|
||||
try {
|
||||
// Enforce mode cannot connect without the key, so it waits for another process that reads it. Warn mode connects without it.
|
||||
if (self::hostKeyCheckingMode($server) === 'enforce') {
|
||||
$lock->block(self::hostKeyScanProcessTimeout($server), $learn);
|
||||
} else {
|
||||
$lock->get($learn);
|
||||
}
|
||||
} catch (LockTimeoutException) {
|
||||
self::loadPinnedHostKey($server);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pinned host key from the database into the settings in memory, without a refresh() that drops unsaved changes.
|
||||
*/
|
||||
private static function loadPinnedHostKey(Server $server): bool
|
||||
{
|
||||
$columns = ['ssh_host_key', 'ssh_host_key_source', 'ssh_host_key_trusted_at', 'ssh_host_key_pending'];
|
||||
$stored = ServerSetting::query()->whereKey($server->settings->getKey())->first($columns);
|
||||
if (blank($stored?->ssh_host_key)) {
|
||||
return false;
|
||||
}
|
||||
$server->settings->forceFill($stored->getAttributes())->syncOriginalAttributes($columns);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function hostKeyScanFailedCacheKey(Server $server): string
|
||||
{
|
||||
return 'ssh_host_key_scan_failed_'.$server->uuid;
|
||||
}
|
||||
|
||||
private static function hostKeyOptions(Server $server): string
|
||||
{
|
||||
self::learnHostKey($server);
|
||||
$mode = self::hostKeyCheckingMode($server);
|
||||
|
||||
if ($mode === 'off') {
|
||||
return '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ';
|
||||
}
|
||||
if (blank($server->settings->ssh_host_key)) {
|
||||
if ($mode === 'enforce') {
|
||||
$scanError = Cache::get(self::hostKeyScanFailedCacheKey($server));
|
||||
throw new \RuntimeException('SSH connection failed: Coolify could not read the SSH host key of the server'.(filled($scanError) ? ": {$scanError}" : '.'), 255);
|
||||
}
|
||||
|
||||
return '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ';
|
||||
}
|
||||
|
||||
return '-o HostKeyAlias='.escapeshellarg(self::hostKeyAlias($server)).' '
|
||||
.'-o UserKnownHostsFile='.escapeshellarg(self::syncKnownHostsFile($server)).' '
|
||||
.'-o StrictHostKeyChecking='.($mode === 'enforce' ? 'yes' : 'no').' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the known_hosts file of the server from the database, like validateSshKey() does for the key file.
|
||||
* The file name has a hash of the content, so a process that loaded an older key cannot overwrite the file of the current key.
|
||||
*/
|
||||
private static function syncKnownHostsFile(Server $server): string
|
||||
{
|
||||
['path' => $path, 'content' => $content] = self::knownHostsFile($server->uuid, $server->settings->ssh_host_key);
|
||||
|
||||
if (@file_get_contents($path) !== $content) {
|
||||
File::ensureDirectoryExists(self::knownHostsDirectory(), 0700);
|
||||
$temporaryPath = $path.'.'.Str::random(8);
|
||||
File::put($temporaryPath, $content);
|
||||
chmod($temporaryPath, 0600);
|
||||
rename($temporaryPath, $path);
|
||||
}
|
||||
self::giveToSshDirectoryOwner(self::knownHostsDirectory(), $path);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the known_hosts files of old host keys of a server. Pass null to delete every file of the server.
|
||||
*/
|
||||
public static function deleteStaleKnownHostsFiles(string $serverUuid, ?string $pinnedKey): void
|
||||
{
|
||||
$keep = filled($pinnedKey) ? self::knownHostsFile($serverUuid, $pinnedKey)['path'] : null;
|
||||
foreach (File::glob(self::knownHostsDirectory().'/known_hosts_'.$serverUuid.'_*') as $file) {
|
||||
if ($file !== $keep && preg_match('/_[0-9a-f]{16}$/', $file) === 1) {
|
||||
File::delete($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path: string, content: string}
|
||||
*/
|
||||
private static function knownHostsFile(string $serverUuid, string $pinnedKey): array
|
||||
{
|
||||
$alias = 'coolify-'.$serverUuid;
|
||||
$content = collect(explode("\n", $pinnedKey))
|
||||
->filter()
|
||||
->map(fn (string $key) => "{$alias} {$key}\n")
|
||||
->implode('');
|
||||
|
||||
return [
|
||||
'path' => self::knownHostsDirectory().'/known_hosts_'.$serverUuid.'_'.substr(hash('sha256', $content), 0, 16),
|
||||
'content' => $content,
|
||||
];
|
||||
}
|
||||
|
||||
private static function knownHostsDirectory(): string
|
||||
{
|
||||
return storage_path('app/ssh/known_hosts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizon runs as root and php-fpm runs as www-data. Files that a root process writes under storage/app/ssh
|
||||
* get the owner of that directory, so that the web process can still read and replace them.
|
||||
*/
|
||||
public static function giveToSshDirectoryOwner(string ...$paths): void
|
||||
{
|
||||
if (! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$owner = @fileowner(storage_path('app/ssh'));
|
||||
$group = @filegroup(storage_path('app/ssh'));
|
||||
if ($owner === false || $owner === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (file_exists($path) && (fileowner($path) !== $owner || filegroup($path) !== $group)) {
|
||||
@chown($path, $owner);
|
||||
@chgrp($path, $group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function hostKeyAlias(Server $server): string
|
||||
{
|
||||
return 'coolify-'.$server->uuid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ class ApplicationsController extends Controller
|
||||
'is_consistent_container_name_enabled',
|
||||
'custom_internal_name',
|
||||
'custom_container_name_prefix',
|
||||
'is_image_scan_enabled',
|
||||
];
|
||||
|
||||
private const BOOLEAN_APPLICATION_SETTING_FIELDS = [
|
||||
@@ -104,6 +105,7 @@ class ApplicationsController extends Controller
|
||||
'is_log_drain_enabled',
|
||||
'is_gpu_enabled',
|
||||
'is_consistent_container_name_enabled',
|
||||
'is_image_scan_enabled',
|
||||
];
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
@@ -498,6 +500,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
@@ -711,6 +714,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
@@ -924,6 +928,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
@@ -1137,6 +1142,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
@@ -1320,6 +1326,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
@@ -1499,6 +1506,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
@@ -3536,6 +3544,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'is_image_scan_enabled' => ['type' => 'boolean', 'description' => 'Scan the images of the application for vulnerabilities. Needs image scanning on the server. Default true.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
|
||||
@@ -108,6 +108,10 @@ class CloudProviderTokensController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'Only team admins and owners can manage cloud provider tokens.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -120,6 +124,9 @@ class CloudProviderTokensController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if (! $request->user()?->isAdminOfTeam((int) $teamId)) {
|
||||
return response()->json(['message' => 'This action is unauthorized.'], 403);
|
||||
}
|
||||
|
||||
$tokens = CloudProviderToken::whereTeamId($teamId)
|
||||
->withCount('servers')
|
||||
@@ -168,6 +175,10 @@ class CloudProviderTokensController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'Only team admins and owners can manage cloud provider tokens.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
@@ -238,6 +249,10 @@ class CloudProviderTokensController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'Only team admins and owners can manage cloud provider tokens.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -357,6 +372,10 @@ class CloudProviderTokensController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'Only team admins and owners can manage cloud provider tokens.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
@@ -464,6 +483,10 @@ class CloudProviderTokensController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'Only team admins and owners can manage cloud provider tokens.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
@@ -543,6 +566,10 @@ class CloudProviderTokensController extends Controller
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'Only team admins and owners can manage cloud provider tokens.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Actions\Service\StartService;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Enums\CancelPreviousDeployments;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SyncGithubDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
@@ -278,6 +279,13 @@ class DeployController extends Controller
|
||||
$deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value;
|
||||
$cancelled = true;
|
||||
|
||||
try {
|
||||
// The query update above fires no model event.
|
||||
SyncGithubDeploymentJob::dispatchFor($deployment);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning("Failed to queue the GitHub deployment sync for cancelled deployment {$deployment->id}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
try {
|
||||
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
|
||||
CleanupCancelledDeployment::run($deployment, (int) $teamId);
|
||||
|
||||
@@ -474,6 +474,18 @@ class GithubController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$errors = [];
|
||||
foreach (['owner' => $owner, 'repo' => $repo] as $field => $value) {
|
||||
if (! preg_match('/^[A-Za-z0-9_.-]+$/', (string) $value) || in_array($value, ['.', '..'], true)) {
|
||||
$errors[$field] = ["The {$field} is invalid."];
|
||||
}
|
||||
}
|
||||
if ($errors) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
@@ -485,7 +497,7 @@ class GithubController extends Controller
|
||||
$response = Http::GitHub($githubApp->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/repos/{$owner}/{$repo}/branches");
|
||||
->get('/repos/'.rawurlencode($owner).'/'.rawurlencode($repo).'/branches');
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
return response()->json([
|
||||
|
||||
@@ -17,6 +17,7 @@ class InstanceEmailSettingsController extends Controller
|
||||
'notification_from_address', 'notification_from_name', 'smtp_host',
|
||||
'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
|
||||
'smtp_timeout', 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key',
|
||||
'resend_from_address', 'resend_from_name',
|
||||
];
|
||||
|
||||
#[OA\Get(
|
||||
@@ -70,6 +71,8 @@ class InstanceEmailSettingsController extends Controller
|
||||
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
|
||||
'resend_enabled' => 'sometimes|boolean',
|
||||
'resend_api_key' => 'sometimes|nullable|string|max:255',
|
||||
'resend_from_address' => 'sometimes|nullable|email',
|
||||
'resend_from_name' => 'sometimes|nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\SlackNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use App\Rules\SafeSmtpHost;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidHostname;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -33,7 +34,7 @@ class NotificationsController extends Controller
|
||||
'smtp_from_address' => 'sometimes|nullable|email',
|
||||
'smtp_from_name' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_recipients' => 'sometimes|nullable|string|max:1000',
|
||||
'smtp_host' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_host' => ['sometimes', 'nullable', 'string', 'max:255', new SafeSmtpHost],
|
||||
'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535',
|
||||
'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none',
|
||||
'smtp_username' => 'sometimes|nullable|string|max:255',
|
||||
@@ -42,6 +43,8 @@ class NotificationsController extends Controller
|
||||
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
|
||||
'resend_enabled' => 'sometimes|boolean',
|
||||
'resend_api_key' => 'sometimes|nullable|string|max:255',
|
||||
'resend_from_address' => 'sometimes|nullable|email',
|
||||
'resend_from_name' => 'sometimes|nullable|string|max:255',
|
||||
'use_instance_email_settings' => 'sometimes|boolean',
|
||||
'deployment_success_email_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_email_notifications' => 'sometimes|boolean',
|
||||
@@ -59,6 +62,7 @@ class NotificationsController extends Controller
|
||||
'server_unreachable_email_notifications' => 'sometimes|boolean',
|
||||
'server_patch_email_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_email_notifications' => 'sometimes|boolean',
|
||||
'image_vulnerabilities_email_notifications' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
'discord' => [
|
||||
@@ -81,6 +85,7 @@ class NotificationsController extends Controller
|
||||
'server_unreachable_discord_notifications' => 'sometimes|boolean',
|
||||
'server_patch_discord_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_discord_notifications' => 'sometimes|boolean',
|
||||
'image_vulnerabilities_discord_notifications' => 'sometimes|boolean',
|
||||
'discord_ping_enabled' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
@@ -104,6 +109,7 @@ class NotificationsController extends Controller
|
||||
'server_unreachable_slack_notifications' => 'sometimes|boolean',
|
||||
'server_patch_slack_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_slack_notifications' => 'sometimes|boolean',
|
||||
'image_vulnerabilities_slack_notifications' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
'telegram' => [
|
||||
@@ -127,6 +133,7 @@ class NotificationsController extends Controller
|
||||
'server_unreachable_telegram_notifications' => 'sometimes|boolean',
|
||||
'server_patch_telegram_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_telegram_notifications' => 'sometimes|boolean',
|
||||
'image_vulnerabilities_telegram_notifications' => 'sometimes|boolean',
|
||||
'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
@@ -142,6 +149,7 @@ class NotificationsController extends Controller
|
||||
'telegram_notifications_server_unreachable_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_server_patch_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_traefik_outdated_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_image_vulnerabilities_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
],
|
||||
],
|
||||
'pushover' => [
|
||||
@@ -165,6 +173,7 @@ class NotificationsController extends Controller
|
||||
'server_unreachable_pushover_notifications' => 'sometimes|boolean',
|
||||
'server_patch_pushover_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_pushover_notifications' => 'sometimes|boolean',
|
||||
'image_vulnerabilities_pushover_notifications' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
'webhook' => [
|
||||
@@ -187,6 +196,7 @@ class NotificationsController extends Controller
|
||||
'server_unreachable_webhook_notifications' => 'sometimes|boolean',
|
||||
'server_patch_webhook_notifications' => 'sometimes|boolean',
|
||||
'traefik_outdated_webhook_notifications' => 'sometimes|boolean',
|
||||
'image_vulnerabilities_webhook_notifications' => 'sometimes|boolean',
|
||||
],
|
||||
],
|
||||
default => throw new \InvalidArgumentException("Unknown notification channel [{$channel}]."),
|
||||
|
||||
@@ -132,6 +132,79 @@ use OpenApi\Attributes as OA;
|
||||
new OA\Property(property: 'deleted_at', type: 'string', format: 'date-time', nullable: true),
|
||||
],
|
||||
),
|
||||
new OA\Schema(
|
||||
schema: 'SecurityStatus',
|
||||
description: 'Security status of a resource from image scanning.',
|
||||
properties: [
|
||||
new OA\Property(property: 'status', type: 'string', enum: ['update_now', 'update_available', 'no_fix', 'check_needed', 'ok', 'not_checked']),
|
||||
new OA\Property(property: 'label', type: 'string'),
|
||||
new OA\Property(property: 'action', type: 'string', nullable: true, description: 'What to do, as the UI shows it.'),
|
||||
new OA\Property(property: 'check_reason', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'problem_count', type: 'integer'),
|
||||
new OA\Property(property: 'exploited_count', type: 'integer'),
|
||||
new OA\Property(property: 'any_exploited', type: 'boolean'),
|
||||
new OA\Property(property: 'critical_count', type: 'integer'),
|
||||
new OA\Property(property: 'high_count', type: 'integer'),
|
||||
new OA\Property(property: 'medium_count', type: 'integer'),
|
||||
new OA\Property(property: 'eol', type: 'object', nullable: true, description: 'The end-of-life OS of the image.'),
|
||||
new OA\Property(property: 'checked_at', type: 'string', format: 'date-time', nullable: true),
|
||||
new OA\Property(property: 'actions', type: 'array', items: new OA\Items(properties: [
|
||||
new OA\Property(property: 'type', type: 'string'),
|
||||
new OA\Property(property: 'sentence', type: 'string'),
|
||||
new OA\Property(property: 'target', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'copy', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'image', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'ref', type: 'string', nullable: true),
|
||||
], type: 'object')),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
new OA\Schema(
|
||||
schema: 'ResourceVulnerabilities',
|
||||
description: 'Image scan results of a resource.',
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'type', type: 'string', enum: ['application', 'service', 'database']),
|
||||
new OA\Property(property: 'off_reason', type: 'string', nullable: true, enum: ['instance', 'server', 'application'], description: 'Why the resource is not scanned. Null when it is scanned.'),
|
||||
new OA\Property(property: 'security', ref: '#/components/schemas/SecurityStatus', nullable: true),
|
||||
new OA\Property(property: 'images', type: 'array', items: new OA\Items(properties: [
|
||||
new OA\Property(property: 'scan_uuid', type: 'string'),
|
||||
new OA\Property(property: 'owner_uuid', type: 'string', description: 'The application, database, or service application or database that runs the image.'),
|
||||
new OA\Property(property: 'image_ref', type: 'string'),
|
||||
new OA\Property(property: 'scan_status', type: 'string', enum: ['queued', 'running', 'completed', 'failed']),
|
||||
new OA\Property(property: 'error_code', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'scanned_at', type: 'string', format: 'date-time', nullable: true),
|
||||
new OA\Property(property: 'truncated', type: 'boolean'),
|
||||
new OA\Property(property: 'security_status', type: 'string'),
|
||||
], type: 'object')),
|
||||
new OA\Property(property: 'findings', type: 'array', items: new OA\Items(properties: [
|
||||
new OA\Property(property: 'id', type: 'string'),
|
||||
new OA\Property(property: 'display_id', type: 'string'),
|
||||
new OA\Property(property: 'aliases', type: 'array', items: new OA\Items(type: 'string')),
|
||||
new OA\Property(property: 'title', type: 'string'),
|
||||
new OA\Property(property: 'severity', type: 'string', enum: ['critical', 'high', 'medium', 'low', 'unknown']),
|
||||
new OA\Property(property: 'package', type: 'string'),
|
||||
new OA\Property(property: 'version', type: 'string'),
|
||||
new OA\Property(property: 'package_type', type: 'string'),
|
||||
new OA\Property(property: 'fixed_in', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'state', type: 'string', enum: ['fixed', 'no_fix', 'ignored'], description: 'fixed: a fix is available upstream.'),
|
||||
new OA\Property(property: 'ignore', type: 'object', nullable: true),
|
||||
new OA\Property(property: 'kev', type: 'boolean', description: 'Listed in the CISA Known Exploited Vulnerabilities catalog.'),
|
||||
new OA\Property(property: 'epss', type: 'number', nullable: true),
|
||||
new OA\Property(property: 'risk', type: 'number', nullable: true),
|
||||
new OA\Property(property: 'cvss_score', type: 'number', nullable: true),
|
||||
new OA\Property(property: 'advisory_url', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'image_ref', type: 'string'),
|
||||
new OA\Property(property: 'owner_uuid', type: 'string'),
|
||||
], type: 'object')),
|
||||
new OA\Property(property: 'pagination', properties: [
|
||||
new OA\Property(property: 'page', type: 'integer'),
|
||||
new OA\Property(property: 'per_page', type: 'integer'),
|
||||
new OA\Property(property: 'total', type: 'integer'),
|
||||
], type: 'object'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\BackupStorageType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\S3Storage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidS3BucketName;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class S3StoragesController extends Controller
|
||||
@@ -17,7 +18,12 @@ class S3StoragesController extends Controller
|
||||
{
|
||||
$storage->makeHidden([
|
||||
'id',
|
||||
'private_key_id',
|
||||
'privateKey',
|
||||
]);
|
||||
if ($storage->type === BackupStorageType::Sftp) {
|
||||
$storage->setAttribute('private_key_uuid', $storage->privateKey?->uuid);
|
||||
}
|
||||
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$storage->makeVisible([
|
||||
@@ -29,13 +35,79 @@ class S3StoragesController extends Controller
|
||||
return serializeApiResponse($storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* The request fields of a storage type. S3 has a bucket, a region, and a URL style; the other types have a URL,
|
||||
* a username (key), and a password (secret).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function connectionFields(BackupStorageType $type): array
|
||||
{
|
||||
return match ($type) {
|
||||
BackupStorageType::S3 => ['endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'skip_tls_verification'],
|
||||
BackupStorageType::Sftp => ['endpoint', 'key', 'secret', 'private_key_uuid'],
|
||||
default => ['endpoint', 'key', 'secret'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* connectionRules() with the private key as a UUID of the team, as the API sends it.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function connectionRules(BackupStorageType $type, int $teamId, bool $partial = false, array $trustedInternalHosts = [], ?string $savedEndpoint = null): array
|
||||
{
|
||||
$rules = S3Storage::connectionRules($type, $partial, $trustedInternalHosts, $teamId, $savedEndpoint);
|
||||
if (array_key_exists('private_key_id', $rules)) {
|
||||
unset($rules['private_key_id']);
|
||||
$rules['private_key_uuid'] = ['nullable', 'string', Rule::exists('private_keys', 'uuid')->where('team_id', $teamId)->where(fn ($query) => $query->where('is_git_related', false))];
|
||||
if (! $partial) {
|
||||
$rules['secret'] = ['required_without:private_key_uuid', 'nullable', 'string', 'max:255'];
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login columns of an SFTP storage from the request: a private key UUID logs in with that key, a password
|
||||
* logs in with the password. Empty when the request changes neither.
|
||||
*
|
||||
* @param array<string, mixed> $body
|
||||
* @return array{private_key_id?: int|null, secret?: string}|JsonResponse
|
||||
*/
|
||||
private function sftpLoginAttributes(array $body, int $teamId, ?string $currentSecret = null): array|JsonResponse
|
||||
{
|
||||
if (filled($body['private_key_uuid'] ?? null)) {
|
||||
return [
|
||||
'private_key_id' => PrivateKey::query()->where('team_id', $teamId)->where('is_git_related', false)->where('uuid', $body['private_key_uuid'])->value('id'),
|
||||
'secret' => '',
|
||||
];
|
||||
}
|
||||
if (filled($body['secret'] ?? null)) {
|
||||
return ['private_key_id' => null, 'secret' => $body['secret']];
|
||||
}
|
||||
if (array_key_exists('private_key_uuid', $body)) {
|
||||
if (blank($currentSecret)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['secret' => ['Send a password (secret) to log in without a private key.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
return ['private_key_id' => null];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{valid: bool, error: string|null}
|
||||
*/
|
||||
private function validateStorageConnection(S3Storage $storage): array
|
||||
{
|
||||
try {
|
||||
$storage->testConnection(shouldSave: true);
|
||||
$storage->testConnection(shouldSave: true, acceptNewHostKey: true);
|
||||
|
||||
return ['valid' => true, 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
@@ -72,7 +144,7 @@ class S3StoragesController extends Controller
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List S3 Storages',
|
||||
description: 'List all S3 storages for the authenticated team.',
|
||||
description: 'List all backup storages (S3, SFTP, FTPS, and WebDAV) for the authenticated team.',
|
||||
path: '/s3-storages',
|
||||
operationId: 'list-s3-storages',
|
||||
security: [
|
||||
@@ -92,13 +164,16 @@ class S3StoragesController extends Controller
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'type' => ['type' => 'string', 'enum' => ['s3', 'sftp', 'ftps', 'webdav']],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string', 'nullable' => true],
|
||||
'endpoint' => ['type' => 'string'],
|
||||
'bucket' => ['type' => 'string'],
|
||||
'region' => ['type' => 'string'],
|
||||
'bucket' => ['type' => 'string', 'nullable' => true, 'description' => 'S3 only. Null for the other types.'],
|
||||
'region' => ['type' => 'string', 'description' => 'S3 only. The other types have a placeholder value.'],
|
||||
'use_path_style_endpoint' => ['type' => 'boolean'],
|
||||
'skip_tls_verification' => ['type' => 'boolean'],
|
||||
'private_key_uuid' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the UUID of the private key used to log in. Null with a password login.'],
|
||||
'sftp_host_key' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the pinned host key of the server, as "<key type> <base64>". Saved by the first successful connection test.'],
|
||||
'is_usable' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
@@ -126,6 +201,7 @@ class S3StoragesController extends Controller
|
||||
}
|
||||
|
||||
$storages = S3Storage::ownedByCurrentTeamAPI($teamId)
|
||||
->with('privateKey:id,uuid')
|
||||
->get()
|
||||
->map(function ($storage) {
|
||||
return $this->removeSensitiveData($storage);
|
||||
@@ -157,13 +233,16 @@ class S3StoragesController extends Controller
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'type' => ['type' => 'string', 'enum' => ['s3', 'sftp', 'ftps', 'webdav']],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string', 'nullable' => true],
|
||||
'endpoint' => ['type' => 'string'],
|
||||
'bucket' => ['type' => 'string'],
|
||||
'region' => ['type' => 'string'],
|
||||
'bucket' => ['type' => 'string', 'nullable' => true, 'description' => 'S3 only. Null for the other types.'],
|
||||
'region' => ['type' => 'string', 'description' => 'S3 only. The other types have a placeholder value.'],
|
||||
'use_path_style_endpoint' => ['type' => 'boolean'],
|
||||
'skip_tls_verification' => ['type' => 'boolean'],
|
||||
'private_key_uuid' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the UUID of the private key used to log in. Null with a password login.'],
|
||||
'sftp_host_key' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the pinned host key of the server, as "<key type> <base64>". Saved by the first successful connection test.'],
|
||||
'is_usable' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
@@ -217,17 +296,19 @@ class S3StoragesController extends Controller
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['name', 'endpoint', 'bucket', 'region', 'key', 'secret'],
|
||||
required: ['name', 'endpoint', 'key'],
|
||||
properties: [
|
||||
'type' => ['type' => 'string', 'enum' => ['s3', 'sftp', 'ftps', 'webdav'], 'default' => 's3', 'description' => 'The storage protocol. It cannot change later.'],
|
||||
'name' => ['type' => 'string', 'example' => 'My S3 Storage', 'description' => 'A friendly name for the storage.'],
|
||||
'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional description.'],
|
||||
'endpoint' => ['type' => 'string', 'example' => 'https://s3.us-east-1.amazonaws.com', 'description' => 'S3-compatible endpoint URL.'],
|
||||
'bucket' => ['type' => 'string', 'example' => 'my-bucket', 'description' => 'S3 bucket name.'],
|
||||
'region' => ['type' => 'string', 'example' => 'us-east-1', 'description' => 'S3 region.'],
|
||||
'endpoint' => ['type' => 'string', 'example' => 'https://s3.us-east-1.amazonaws.com', 'description' => 'S3: the S3-compatible endpoint URL. SFTP and FTPS: sftp://host:port/directory or ftps://host:port/directory (explicit TLS); the directory is an absolute path, and without one the login directory is used. WebDAV: the https URL of the backup directory.'],
|
||||
'bucket' => ['type' => 'string', 'example' => 'my-bucket', 'description' => 'S3 bucket name. Required for S3, not allowed for the other types.'],
|
||||
'region' => ['type' => 'string', 'example' => 'us-east-1', 'description' => 'S3 region. Required for S3, not allowed for the other types.'],
|
||||
'use_path_style_endpoint' => ['type' => 'boolean', 'description' => 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint). Default: false for Alibaba Cloud OSS, Tencent Cloud COS, and Volcengine TOS endpoints, true for all other endpoints.'],
|
||||
'skip_tls_verification' => ['type' => 'boolean', 'description' => 'Do not check the TLS certificate of the endpoint. Use only for endpoints with a self-signed or private CA certificate. Default: false.'],
|
||||
'key' => ['type' => 'string', 'description' => 'Access key.'],
|
||||
'secret' => ['type' => 'string', 'description' => 'Secret key.'],
|
||||
'skip_tls_verification' => ['type' => 'boolean', 'description' => 'S3 only. Do not check the TLS certificate of the endpoint. Use only for endpoints with a self-signed or private CA certificate. Default: false.'],
|
||||
'key' => ['type' => 'string', 'description' => 'S3: the access key. Other types: the username.'],
|
||||
'secret' => ['type' => 'string', 'description' => 'S3: the secret key. Other types: the password. SFTP: not needed with private_key_uuid.'],
|
||||
'private_key_uuid' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the UUID of a private key of your team to log in with instead of a password.'],
|
||||
'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'],
|
||||
],
|
||||
),
|
||||
@@ -264,7 +345,6 @@ class S3StoragesController extends Controller
|
||||
)]
|
||||
public function store(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'skip_tls_verification', 'is_usable'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -278,15 +358,16 @@ class S3StoragesController extends Controller
|
||||
}
|
||||
|
||||
$body = $request->json()->all();
|
||||
$rawType = $body['type'] ?? 's3';
|
||||
$type = is_string($rawType) ? BackupStorageType::tryFrom($rawType) : null;
|
||||
$isS3 = $type === BackupStorageType::S3;
|
||||
$connectionFields = $this->connectionFields($type ?? BackupStorageType::S3);
|
||||
|
||||
$validationError = $this->validateBody($body, $allowedFields, [
|
||||
$validationError = $this->validateBody($body, ['type', 'name', 'description', 'is_usable', ...$connectionFields], [
|
||||
'type' => ['sometimes', Rule::enum(BackupStorageType::class)],
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'endpoint' => ['required', 'string', 'max:255', new SafeWebhookUrl],
|
||||
'bucket' => ['required', new ValidS3BucketName],
|
||||
'region' => 'required|string|max:255',
|
||||
'key' => 'required|string|max:255',
|
||||
'secret' => 'required|string|max:255',
|
||||
...$this->connectionRules($type ?? BackupStorageType::S3, $teamId),
|
||||
'use_path_style_endpoint' => 'sometimes|boolean',
|
||||
'skip_tls_verification' => 'sometimes|boolean',
|
||||
'is_usable' => 'sometimes|boolean',
|
||||
@@ -294,18 +375,24 @@ class S3StoragesController extends Controller
|
||||
if ($validationError instanceof JsonResponse) {
|
||||
return $validationError;
|
||||
}
|
||||
$login = $type === BackupStorageType::Sftp ? $this->sftpLoginAttributes($body, $teamId) : [];
|
||||
if ($login instanceof JsonResponse) {
|
||||
return $login;
|
||||
}
|
||||
|
||||
$storage = S3Storage::create([
|
||||
'team_id' => $teamId,
|
||||
'type' => $type,
|
||||
'name' => $body['name'],
|
||||
'description' => $body['description'] ?? null,
|
||||
'endpoint' => $body['endpoint'],
|
||||
'bucket' => $body['bucket'],
|
||||
'region' => $body['region'],
|
||||
'bucket' => $isS3 ? $body['bucket'] : null,
|
||||
'region' => $isS3 ? $body['region'] : 'us-east-1',
|
||||
'key' => $body['key'],
|
||||
'secret' => $body['secret'],
|
||||
'use_path_style_endpoint' => $body['use_path_style_endpoint'] ?? S3Storage::detectPathStyleEndpoint($body['endpoint']),
|
||||
'skip_tls_verification' => $body['skip_tls_verification'] ?? false,
|
||||
'secret' => $body['secret'] ?? '',
|
||||
...$login,
|
||||
'use_path_style_endpoint' => $isS3 ? ($body['use_path_style_endpoint'] ?? S3Storage::detectPathStyleEndpoint($body['endpoint'])) : true,
|
||||
'skip_tls_verification' => $isS3 && ($body['skip_tls_verification'] ?? false),
|
||||
'is_usable' => $body['is_usable'] ?? false,
|
||||
]);
|
||||
|
||||
@@ -322,7 +409,7 @@ class S3StoragesController extends Controller
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update S3 Storage',
|
||||
description: 'Update S3 storage by UUID.',
|
||||
description: 'Update S3 storage by UUID. The type cannot change, and only the fields of the storage type are accepted.',
|
||||
path: '/s3-storages/{uuid}',
|
||||
operationId: 'update-s3-storage-by-uuid',
|
||||
security: [
|
||||
@@ -342,13 +429,14 @@ class S3StoragesController extends Controller
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'A friendly name for the storage.'],
|
||||
'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional description.'],
|
||||
'endpoint' => ['type' => 'string', 'description' => 'S3-compatible endpoint URL.'],
|
||||
'bucket' => ['type' => 'string', 'description' => 'S3 bucket name.'],
|
||||
'region' => ['type' => 'string', 'description' => 'S3 region.'],
|
||||
'endpoint' => ['type' => 'string', 'description' => 'The endpoint URL, in the format of the storage type (see Create S3 Storage).'],
|
||||
'bucket' => ['type' => 'string', 'description' => 'S3 bucket name. Only for S3 storages.'],
|
||||
'region' => ['type' => 'string', 'description' => 'S3 region. Only for S3 storages.'],
|
||||
'use_path_style_endpoint' => ['type' => 'boolean', 'description' => 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint), such as Alibaba Cloud OSS.'],
|
||||
'skip_tls_verification' => ['type' => 'boolean', 'description' => 'Do not check the TLS certificate of the endpoint. Use only for endpoints with a self-signed or private CA certificate. Default: false.'],
|
||||
'key' => ['type' => 'string', 'description' => 'Access key.'],
|
||||
'secret' => ['type' => 'string', 'description' => 'Secret key.'],
|
||||
'skip_tls_verification' => ['type' => 'boolean', 'description' => 'S3 only. Do not check the TLS certificate of the endpoint. Use only for endpoints with a self-signed or private CA certificate. Default: false.'],
|
||||
'key' => ['type' => 'string', 'description' => 'S3: the access key. Other types: the username.'],
|
||||
'secret' => ['type' => 'string', 'description' => 'S3: the secret key. Other types: the password. SFTP: sending a password switches the login to the password.'],
|
||||
'private_key_uuid' => ['type' => 'string', 'nullable' => true, 'description' => 'SFTP only: the UUID of a private key of your team to log in with. Null switches the login to the saved password.'],
|
||||
'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'],
|
||||
],
|
||||
),
|
||||
@@ -385,8 +473,6 @@ class S3StoragesController extends Controller
|
||||
)]
|
||||
public function update(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'skip_tls_verification', 'is_usable'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
@@ -399,14 +485,17 @@ class S3StoragesController extends Controller
|
||||
|
||||
$body = $request->json()->all();
|
||||
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->route('uuid'))->first();
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'S3 storage not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $storage);
|
||||
|
||||
$allowedFields = ['name', 'description', 'is_usable', ...$this->connectionFields($storage->type)];
|
||||
$validationError = $this->validateBody($body, $allowedFields, [
|
||||
'name' => ValidationPatterns::nameRules(required: false),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'endpoint' => ['sometimes', 'string', 'max:255', new SafeWebhookUrl],
|
||||
'bucket' => ['sometimes', new ValidS3BucketName],
|
||||
'region' => 'sometimes|string|max:255',
|
||||
'key' => 'sometimes|string|max:255',
|
||||
'secret' => 'sometimes|string|max:255',
|
||||
...$this->connectionRules($storage->type, $teamId, partial: true, trustedInternalHosts: $storage->trustedInternalHosts(), savedEndpoint: $storage->endpoint),
|
||||
'use_path_style_endpoint' => 'sometimes|boolean',
|
||||
'skip_tls_verification' => 'sometimes|boolean',
|
||||
'is_usable' => 'sometimes|boolean',
|
||||
@@ -414,14 +503,17 @@ class S3StoragesController extends Controller
|
||||
if ($validationError instanceof JsonResponse) {
|
||||
return $validationError;
|
||||
}
|
||||
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->route('uuid'))->first();
|
||||
if (! $storage) {
|
||||
return response()->json(['message' => 'S3 storage not found.'], 404);
|
||||
$login = $storage->type === BackupStorageType::Sftp ? $this->sftpLoginAttributes($body, $teamId, $storage->secret) : [];
|
||||
if ($login instanceof JsonResponse) {
|
||||
return $login;
|
||||
}
|
||||
$this->authorize('update', $storage);
|
||||
|
||||
$storage->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||
$attributes = array_intersect_key($body, array_flip($allowedFields));
|
||||
unset($attributes['private_key_uuid']);
|
||||
if ($storage->type === BackupStorageType::Sftp && blank($attributes['secret'] ?? null)) {
|
||||
unset($attributes['secret']);
|
||||
}
|
||||
$storage->update([...$attributes, ...$login]);
|
||||
|
||||
auditLog('api.s3_storage.updated', [
|
||||
'team_id' => $teamId,
|
||||
@@ -513,7 +605,7 @@ class S3StoragesController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Validate S3 Storage',
|
||||
description: 'Validate an S3 storage connection using ListObjectsV2.',
|
||||
description: 'Validate a storage connection. S3: lists the bucket (ListObjectsV2). SFTP, FTPS, and WebDAV: lists the backup directory; for SFTP, the first successful validation pins the host key of the server.',
|
||||
path: '/s3-storages/{uuid}/validate',
|
||||
operationId: 'validate-s3-storage-by-uuid',
|
||||
security: [
|
||||
@@ -574,7 +666,7 @@ class S3StoragesController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'valid' => $validation['valid'],
|
||||
'message' => $validation['valid'] ? 'S3 storage connection is valid.' : $validation['error'],
|
||||
'message' => $validation['valid'] ? $storage->storageType()->label().' storage connection is valid.' : $validation['error'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\PrivateKey;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class SecurityController extends Controller
|
||||
@@ -208,21 +209,16 @@ class SecurityController extends Controller
|
||||
$request->offsetSet('description', 'Created by Coolify via API');
|
||||
}
|
||||
|
||||
// A key that is not PEM text can be sent base64 encoded. Other text is validated as it is.
|
||||
$isPrivateKeyString = str_starts_with($request->private_key, '-----BEGIN');
|
||||
if (! $isPrivateKeyString) {
|
||||
try {
|
||||
$base64PrivateKey = base64_decode($request->private_key);
|
||||
$request->offsetSet('private_key', $base64PrivateKey);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid private key.',
|
||||
], 422);
|
||||
}
|
||||
$base64PrivateKey = $isPrivateKeyString ? false : base64_decode($request->private_key, true);
|
||||
if ($base64PrivateKey !== false) {
|
||||
$request->offsetSet('private_key', $base64PrivateKey);
|
||||
}
|
||||
$isPrivateKeyValid = PrivateKey::validatePrivateKey($request->private_key);
|
||||
if (! $isPrivateKeyValid) {
|
||||
$privateKeyError = PrivateKey::validationError($request->private_key);
|
||||
if ($privateKeyError) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid private key.',
|
||||
'message' => $privateKeyError,
|
||||
], 422);
|
||||
}
|
||||
$fingerPrint = PrivateKey::generateFingerprint($request->private_key);
|
||||
@@ -352,7 +348,14 @@ class SecurityController extends Controller
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('update', $foundKey);
|
||||
$foundKey->update($request->only($allowedFields));
|
||||
try {
|
||||
$foundKey->update($request->only($allowedFields));
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
auditLog('api.private_key.updated', [
|
||||
'team_id' => $teamId,
|
||||
|
||||
@@ -34,7 +34,12 @@ class SentinelController extends Controller
|
||||
}
|
||||
$naked_token = str_replace('Bearer ', '', $token);
|
||||
try {
|
||||
$decrypted = decrypt($naked_token);
|
||||
// Tokens are encrypt(json) values, so the payload is a serialized string.
|
||||
// Unserialize it without classes: the endpoint has no login yet.
|
||||
$decrypted = unserialize(decrypt($naked_token, false), ['allowed_classes' => false]);
|
||||
if (! is_string($decrypted)) {
|
||||
throw new Exception('Sentinel token payload is not a string.');
|
||||
}
|
||||
$decrypted_token = json_decode($decrypted, true);
|
||||
} catch (Exception $e) {
|
||||
auditLogWebhookFailure('sentinel', 'decrypt_failed');
|
||||
@@ -83,7 +88,7 @@ class SentinelController extends Controller
|
||||
return response()->json(['message' => 'Server is not functional'], 401, ['Retry-After' => 300]);
|
||||
}
|
||||
|
||||
if ($server->settings->sentinel_token !== $naked_token) {
|
||||
if (! hash_equals((string) $server->settings->sentinel_token, (string) $naked_token)) {
|
||||
auditLogWebhookFailure('sentinel', 'token_mismatch', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\ImageScan\SetServerImageScanning;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\PrepareImageScannerJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Services\ImageScan\ImageScanningRefused;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerImageScanningController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = ['is_image_scan_enabled'];
|
||||
|
||||
/**
|
||||
* @return array{is_image_scanning_allowed: bool, is_image_scan_enabled: bool, scanner_state: ?string, scanner_failed_step: ?string, scanner_built_at: ?string, scanner_disk_used_bytes: ?int}
|
||||
*/
|
||||
private function transform(Server $server): array
|
||||
{
|
||||
$state = Cache::get(PrepareImageScannerJob::stateCacheKey($server));
|
||||
$failedStep = Cache::get(PrepareImageScannerJob::failedStepCacheKey($server));
|
||||
$builtAt = Cache::get(PrepareImageScannerJob::builtAtCacheKey($server));
|
||||
$diskUsed = Cache::get(PrepareImageScannerJob::diskUsedCacheKey($server));
|
||||
|
||||
return [
|
||||
'is_image_scanning_allowed' => (bool) InstanceSettings::get()->is_image_scanning_allowed,
|
||||
'is_image_scan_enabled' => (bool) $server->settings->is_image_scan_enabled,
|
||||
'scanner_state' => is_string($state) ? $state : null,
|
||||
'scanner_failed_step' => is_string($failedStep) ? $failedStep : null,
|
||||
'scanner_built_at' => is_string($builtAt) ? CarbonImmutable::parse($builtAt)->toIso8601String() : null,
|
||||
'scanner_disk_used_bytes' => is_int($diskUsed) ? $diskUsed : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function findServer(Request $request): ?Server
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
return is_null($teamId) ? null : Server::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get image scanning settings',
|
||||
description: 'Get the image vulnerability scanning settings of a server owned by the authenticated team, and the state of the scanner on the server. Needs a team admin or owner.',
|
||||
path: '/servers/{uuid}/image-scanning',
|
||||
operationId: 'get-server-image-scanning',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Image scanning settings.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_image_scanning_allowed', type: 'boolean', readOnly: true, description: 'Whether the Coolify instance allows image scanning.'),
|
||||
new OA\Property(property: 'is_image_scan_enabled', type: 'boolean', description: 'Whether the server scans the images of its resources.'),
|
||||
new OA\Property(property: 'scanner_state', type: 'string', nullable: true, enum: ['pulling', 'updating_database', 'ready', 'failed'], description: 'State of the scanner on the server. Null when unknown.'),
|
||||
new OA\Property(property: 'scanner_failed_step', type: 'string', nullable: true, description: 'The step that failed when scanner_state is failed.'),
|
||||
new OA\Property(property: 'scanner_built_at', type: 'string', format: 'date-time', nullable: true, description: 'Time of the vulnerability database on the server.'),
|
||||
new OA\Property(property: 'scanner_disk_used_bytes', type: 'integer', nullable: true, description: 'Disk space that the scanner uses on the server.'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Needs a team admin or owner.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
if (is_null(getTeamIdFromToken())) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = $this->findServer($request);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('viewSecurity', $server);
|
||||
|
||||
return response()->json($this->transform($server));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update image scanning settings',
|
||||
description: 'Turn image vulnerability scanning of a server owned by the authenticated team on or off. Turning it on gets the scanner on the server in the background.',
|
||||
path: '/servers/{uuid}/image-scanning',
|
||||
operationId: 'update-server-image-scanning',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['is_image_scan_enabled'],
|
||||
properties: [
|
||||
new OA\Property(property: 'is_image_scan_enabled', type: 'boolean'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated image scanning settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 403, description: 'Needs a team admin or owner.'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'The instance does not allow image scanning, or another Coolify instance manages the server.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = $this->findServer($request);
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'is_image_scan_enabled' => 'required|boolean',
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$enabled = $request->boolean('is_image_scan_enabled');
|
||||
try {
|
||||
SetServerImageScanning::run($server, $enabled);
|
||||
} catch (ImageScanningRefused $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 409);
|
||||
}
|
||||
|
||||
auditLog($enabled ? 'api.server.image_scanning_enabled' : 'api.server.image_scanning_disabled', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($server->refresh()));
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\AuthorizedKeys;
|
||||
use App\Actions\Server\DeleteServer;
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Enums\ServerRole;
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Jobs\ServerConnectionCheckJob;
|
||||
use App\Jobs\ValidateAndInstallServerJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server as ModelsServer;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
@@ -50,7 +54,9 @@ class ServersController extends Controller
|
||||
}
|
||||
|
||||
return serializeApiResponse($settings)
|
||||
->put('is_build_server', $settings->effectiveServerRole() === ServerRole::BUILD);
|
||||
->put('is_build_server', $settings->effectiveServerRole() === ServerRole::BUILD)
|
||||
->put('ssh_host_key_fingerprints', ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key))
|
||||
->put('ssh_host_key_pending_fingerprints', ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key_pending));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,6 +505,7 @@ class ServersController extends Controller
|
||||
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role.'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Instant validate.'],
|
||||
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'example' => 'traefik', 'description' => 'The proxy type.'],
|
||||
'ssh_host_key' => ['type' => 'string', 'example' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...', 'description' => 'The SSH host key(s) of the server, as public key lines (for example the content of /etc/ssh/ssh_host_ed25519_key.pub) or known_hosts lines. Without it, Coolify trusts the key that the server sends on the first connection.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -538,7 +545,7 @@ class ServersController extends Controller
|
||||
)]
|
||||
public function create_server(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type'];
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type', 'ssh_host_key'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -561,6 +568,7 @@ class ServersController extends Controller
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
'ssh_host_key' => 'string|nullable|max:16384',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
...ValidationPatterns::combinedMessages(),
|
||||
@@ -568,8 +576,13 @@ class ServersController extends Controller
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$rawSshHostKey = $request->input('ssh_host_key');
|
||||
$sshHostKey = is_string($rawSshHostKey) ? ServerSetting::normalizeSshHostKeys($rawSshHostKey) : null;
|
||||
if ($validator->fails() || ! empty($extraFields) || ($request->filled('ssh_host_key') && $sshHostKey === null)) {
|
||||
$errors = $validator->errors();
|
||||
if ($request->filled('ssh_host_key') && $sshHostKey === null && ! $errors->has('ssh_host_key')) {
|
||||
$errors->add('ssh_host_key', 'The SSH host key must contain public key lines ("type base64") or known_hosts lines.');
|
||||
}
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
@@ -651,6 +664,13 @@ class ServersController extends Controller
|
||||
'server_role' => $serverRole,
|
||||
'is_build_server' => $serverRole === ServerRole::BUILD,
|
||||
]);
|
||||
if ($sshHostKey !== null) {
|
||||
$server->settings->refresh()->forceFill([
|
||||
'ssh_host_key' => $sshHostKey,
|
||||
'ssh_host_key_source' => 'manual',
|
||||
'ssh_host_key_trusted_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
@@ -965,6 +985,13 @@ class ServersController extends Controller
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'remove_ssh_key',
|
||||
in: 'query',
|
||||
description: 'Remove the public key of the server\'s SSH key from ~/.ssh/authorized_keys on the server before the server is deleted. The server must have no resources. If the key cannot be removed, the server is not deleted.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission to delete the resources or the cloud server.'),
|
||||
@@ -1022,11 +1049,15 @@ class ServersController extends Controller
|
||||
if (is_null($deleteFromProvider)) {
|
||||
return response()->json(['message' => 'delete_from_provider must be a boolean.'], 422);
|
||||
}
|
||||
$removeSshKey = filter_var($request->query('remove_ssh_key', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if (is_null($removeSshKey)) {
|
||||
return response()->json(['message' => 'remove_ssh_key must be a boolean.'], 422);
|
||||
}
|
||||
// Another Coolify instance manages a transferred server. This instance deletes only its own records:
|
||||
// never the cloud server, the containers, or the DNS records that the other instance uses.
|
||||
if ($server->isTransferredAway()) {
|
||||
if ($deleteFromProvider) {
|
||||
return response()->json(['message' => 'This server is managed by another Coolify instance. Delete its cloud server from that instance.'], 422);
|
||||
if ($deleteFromProvider || $removeSshKey) {
|
||||
return response()->json(['message' => 'This server is managed by another Coolify instance. Delete its cloud server or SSH key from that instance.'], 422);
|
||||
}
|
||||
app(ServerTransferClaimer::class)->deleteArchive($server);
|
||||
auditLog('api.server.deleted', ['team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'ip' => $server->ip, 'archive' => true]);
|
||||
@@ -1062,6 +1093,22 @@ class ServersController extends Controller
|
||||
return $response;
|
||||
}
|
||||
|
||||
// The key is removed before the server record, while the connection still works.
|
||||
if ($removeSshKey && ! $deleteFromProvider) {
|
||||
if ($server->definedResources()->count() > 0) {
|
||||
return response()->json(['message' => 'Delete the resources of the server first, then delete the server with remove_ssh_key.'], 422);
|
||||
}
|
||||
$context = ['team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'private_key_uuid' => $server->privateKey?->uuid];
|
||||
try {
|
||||
$removed = AuthorizedKeys::remove($server, $server->privateKey);
|
||||
} catch (\Throwable $e) {
|
||||
auditLog('api.server.ssh_key_removed', [...$context, 'result' => 'failed'], 'warning');
|
||||
|
||||
return response()->json(['message' => 'The SSH key was not removed from the server, so the server was not deleted: '.remove_iip($e->getMessage())], 422);
|
||||
}
|
||||
auditLog('api.server.ssh_key_removed', [...$context, 'result' => $removed ? 'removed' : 'kept_for_other_server']);
|
||||
}
|
||||
|
||||
// Applications that use the server only as an additional destination keep running on their other servers.
|
||||
$server->detachAdditionalApplications();
|
||||
if ($force) {
|
||||
@@ -1093,6 +1140,7 @@ class ServersController extends Controller
|
||||
'ip' => $deletedIp,
|
||||
'force' => $force,
|
||||
'delete_from_provider' => $deleteFromProvider,
|
||||
'remove_ssh_key' => $removeSshKey,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
@@ -1213,6 +1261,127 @@ class ServersController extends Controller
|
||||
return response()->json(['message' => $message], 201);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Reset host key',
|
||||
description: 'Remove the trusted SSH host key of the server, or trust the given key. Without ssh_host_key, Coolify trusts the key that the server sends on the next connection.',
|
||||
path: '/servers/{uuid}/host-key/reset',
|
||||
operationId: 'reset-server-host-key-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: false,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(
|
||||
property: 'ssh_host_key',
|
||||
description: 'The SSH host key(s) to trust, as public key lines or known_hosts lines.',
|
||||
type: 'string',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Host key reset.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Host key reset.'],
|
||||
'ssh_host_key_fingerprints' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'Only team admins and owners can reset the host key.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function reset_host_key(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
if (! $server->canBeValidated()) {
|
||||
return response()->json([
|
||||
'message' => 'This server was transferred to another Coolify instance.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'ssh_host_key' => 'string|nullable|max:16384',
|
||||
]);
|
||||
$rawSshHostKey = $request->input('ssh_host_key');
|
||||
$sshHostKey = is_string($rawSshHostKey) ? ServerSetting::normalizeSshHostKeys($rawSshHostKey) : null;
|
||||
if ($validator->fails() || ($request->filled('ssh_host_key') && $sshHostKey === null)) {
|
||||
$errors = $validator->errors();
|
||||
if (! $errors->has('ssh_host_key')) {
|
||||
$errors->add('ssh_host_key', 'The SSH host key must contain public key lines ("type base64") or known_hosts lines.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$settings = $server->settings;
|
||||
$previousFingerprints = ServerSetting::sshHostKeyFingerprints($settings->ssh_host_key);
|
||||
$settings->forceFill([
|
||||
'ssh_host_key' => $sshHostKey,
|
||||
'ssh_host_key_source' => $sshHostKey ? 'manual' : null,
|
||||
'ssh_host_key_trusted_at' => $sshHostKey ? now() : null,
|
||||
'ssh_host_key_pending' => null,
|
||||
])->save();
|
||||
SshMultiplexingHelper::removeMuxFile($server);
|
||||
ServerConnectionCheckJob::dispatch($server);
|
||||
|
||||
auditLog('api.server.ssh_host_key.reset', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'previous_fingerprints' => $previousFingerprints,
|
||||
'fingerprints' => ServerSetting::sshHostKeyFingerprints($sshHostKey),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Host key reset.',
|
||||
'ssh_host_key_fingerprints' => ServerSetting::sshHostKeyFingerprints($sshHostKey),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com".
|
||||
*/
|
||||
|
||||
@@ -238,12 +238,16 @@ class TeamController extends Controller
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['is_build_server_fallback_enabled'],
|
||||
minProperties: 1,
|
||||
properties: [
|
||||
'is_build_server_fallback_enabled' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.',
|
||||
],
|
||||
'scan_images_on_new_servers' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Whether image scanning is turned on for servers added to the team from now on.',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -269,9 +273,10 @@ class TeamController extends Controller
|
||||
|
||||
$this->authorize('update', $team);
|
||||
|
||||
$allowedFields = ['is_build_server_fallback_enabled'];
|
||||
$allowedFields = ['is_build_server_fallback_enabled', 'scan_images_on_new_servers'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'is_build_server_fallback_enabled' => ['required', 'boolean'],
|
||||
'is_build_server_fallback_enabled' => ['required_without:scan_images_on_new_servers', 'boolean'],
|
||||
'scan_images_on_new_servers' => ['required_without:is_build_server_fallback_enabled', 'boolean'],
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ImageScan;
|
||||
use App\Models\ImageScanIgnore;
|
||||
use App\Models\Service;
|
||||
use App\Services\ImageScan\Finding;
|
||||
use App\Services\ImageScan\ImageScanPolicyResolver;
|
||||
use App\Services\ImageScan\RelevanceRules;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use App\Services\ImageScan\ResourceSecurity;
|
||||
use App\Services\ImageScan\SecurityAction;
|
||||
use App\Services\ImageScan\SecurityActions;
|
||||
use App\Services\ImageScan\SecurityStatus;
|
||||
use App\Services\ImageScan\TeamVulnerabilities;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
/**
|
||||
* Image scan results, with the same data and permissions as the Security pages of the UI.
|
||||
*/
|
||||
#[OA\Parameter(parameter: 'VulnerabilityState', name: 'state', in: 'query', required: false, description: 'Comma-separated finding states: fixed, no_fix, ignored. All when not sent.', schema: new OA\Schema(type: 'string'))]
|
||||
#[OA\Parameter(parameter: 'VulnerabilitySeverity', name: 'severity', in: 'query', required: false, description: 'Comma-separated severities: critical, high, medium, low, unknown.', schema: new OA\Schema(type: 'string'))]
|
||||
#[OA\Parameter(parameter: 'VulnerabilityImage', name: 'image', in: 'query', required: false, description: 'Only the findings of this scanned image ref.', schema: new OA\Schema(type: 'string'))]
|
||||
#[OA\Parameter(parameter: 'VulnerabilitySearch', name: 'search', in: 'query', required: false, description: 'Text in the vulnerability ID, an alias, the package, or the version.', schema: new OA\Schema(type: 'string'))]
|
||||
#[OA\Parameter(parameter: 'VulnerabilityPage', name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1, minimum: 1))]
|
||||
#[OA\Parameter(parameter: 'VulnerabilityPerPage', name: 'per_page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 50, maximum: 200, minimum: 1))]
|
||||
class VulnerabilitiesController extends Controller
|
||||
{
|
||||
private const SEVERITY_RANK = ['critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1, 'unknown' => 0];
|
||||
|
||||
private const FINDING_STATES = ['fixed', 'no_fix', 'ignored'];
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List vulnerabilities',
|
||||
description: 'List the applications, services, and databases of the authenticated team with their stored security status from image scanning.',
|
||||
path: '/vulnerabilities',
|
||||
operationId: 'list-vulnerabilities',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Vulnerabilities'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'status', in: 'query', required: false, description: 'Comma-separated statuses to return.', schema: new OA\Schema(type: 'string', example: 'update_now,update_available')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Resources with their security status, worst first.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_image_scanning_allowed', type: 'boolean'),
|
||||
new OA\Property(property: 'resources', type: 'array', items: new OA\Items(
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'type', type: 'string', enum: ['application', 'service', 'database']),
|
||||
new OA\Property(property: 'project_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'environment_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'server_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'images', properties: [
|
||||
new OA\Property(property: 'update', type: 'integer'),
|
||||
new OA\Property(property: 'total', type: 'integer'),
|
||||
], type: 'object'),
|
||||
new OA\Property(property: 'security', ref: '#/components/schemas/SecurityStatus'),
|
||||
],
|
||||
type: 'object',
|
||||
)),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
],
|
||||
)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$statuses = $this->listParameter($request, 'status', TeamVulnerabilities::STATUS_ORDER);
|
||||
if ($statuses instanceof JsonResponse) {
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
$rank = array_flip(TeamVulnerabilities::STATUS_ORDER);
|
||||
$resources = TeamVulnerabilities::load((int) $teamId)->entries
|
||||
->filter(fn (array $entry): bool => $statuses === [] || in_array($entry['security']->status->value, $statuses, true))
|
||||
->sortBy(fn (array $entry): array => [$rank[$entry['security']->status->value] ?? PHP_INT_MAX, (string) $entry['resource']->getAttribute('name')])
|
||||
->map(fn (array $entry): array => [
|
||||
'uuid' => (string) $entry['resource']->getAttribute('uuid'),
|
||||
'name' => (string) $entry['resource']->getAttribute('name'),
|
||||
'type' => $this->resourceType($entry['resource']),
|
||||
'project_uuid' => $entry['project']?->uuid,
|
||||
'environment_uuid' => $entry['environment']?->uuid,
|
||||
'server_uuid' => $entry['server']?->uuid,
|
||||
'images' => $entry['images'],
|
||||
'security' => $this->security($entry['security']),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'is_image_scanning_allowed' => (bool) instanceSettings()->is_image_scanning_allowed,
|
||||
'resources' => $resources,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get application vulnerabilities',
|
||||
description: 'Get the security status, the scanned images, and the vulnerabilities of an application.',
|
||||
path: '/applications/{uuid}/vulnerabilities',
|
||||
operationId: 'get-application-vulnerabilities',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Vulnerabilities'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Application UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function application(Request $request): JsonResponse
|
||||
{
|
||||
return $this->show($request, 'application');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service vulnerabilities',
|
||||
description: 'Get the security status, the scanned images, and the vulnerabilities of the applications and databases of a service.',
|
||||
path: '/services/{uuid}/vulnerabilities',
|
||||
operationId: 'get-service-vulnerabilities',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Vulnerabilities'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function service(Request $request): JsonResponse
|
||||
{
|
||||
return $this->show($request, 'service');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get database vulnerabilities',
|
||||
description: 'Get the security status, the scanned image, and the vulnerabilities of a standalone database.',
|
||||
path: '/databases/{uuid}/vulnerabilities',
|
||||
operationId: 'get-database-vulnerabilities',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Vulnerabilities'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Database UUID', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityState'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilitySeverity'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityImage'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilitySearch'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityPage'),
|
||||
new OA\Parameter(ref: '#/components/parameters/VulnerabilityPerPage'),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Scan results.', content: new OA\JsonContent(ref: '#/components/schemas/ResourceVulnerabilities')),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function database(Request $request): JsonResponse
|
||||
{
|
||||
return $this->show($request, 'database');
|
||||
}
|
||||
|
||||
private function show(Request $request, string $type): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->query(), [
|
||||
'state' => 'nullable|string',
|
||||
'severity' => 'nullable|string',
|
||||
'image' => 'nullable|string|max:1000',
|
||||
'search' => 'nullable|string|max:200',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'per_page' => 'nullable|integer|min:1|max:200',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
$states = $this->listParameter($request, 'state', self::FINDING_STATES);
|
||||
$severities = $this->listParameter($request, 'severity', array_keys(self::SEVERITY_RANK));
|
||||
foreach ([$states, $severities] as $parameter) {
|
||||
if ($parameter instanceof JsonResponse) {
|
||||
return $parameter;
|
||||
}
|
||||
}
|
||||
|
||||
$resource = ResourceScans::find($type, (string) $request->route('uuid'), [(int) $teamId]);
|
||||
if (! $resource instanceof Model) {
|
||||
return response()->json(['message' => ucfirst($type).' not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
$offState = ImageScanPolicyResolver::offState($resource, ImageScanPolicyResolver::for($resource)->scanServer);
|
||||
$scans = $offState === null ? ResourceScans::current($resource) : collect();
|
||||
$rules = [];
|
||||
$rulesFor = function (Model $owner) use (&$rules): RelevanceRules {
|
||||
return $rules[$owner->getMorphClass().':'.$owner->getKey()] ??= RelevanceRules::forResource($owner);
|
||||
};
|
||||
|
||||
$imageStatuses = $scans->mapWithKeys(fn (ImageScan $scan): array => [(string) $scan->uuid => SecurityStatus::forScan($scan, $rulesFor($scan->resource))])->all();
|
||||
$findings = $this->findings($scans, $rulesFor);
|
||||
|
||||
$image = trim((string) $request->query('image', ''));
|
||||
$search = mb_strtolower(trim((string) $request->query('search', '')));
|
||||
$findings = array_values(array_filter($findings, fn (array $row): bool => ($states === [] || in_array($row['state'], $states, true))
|
||||
&& ($severities === [] || in_array($row['severity'], $severities, true))
|
||||
&& ($image === '' || $row['image_ref'] === $image)
|
||||
&& ($search === '' || str_contains(mb_strtolower(implode(' ', [$row['id'], ...$row['aliases'], $row['package'], $row['version']])), $search))));
|
||||
|
||||
$perPage = (int) $request->query('per_page', 50);
|
||||
$page = (int) $request->query('page', 1);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => (string) $resource->getAttribute('uuid'),
|
||||
'type' => $type,
|
||||
'off_reason' => $offState,
|
||||
'security' => $scans->isEmpty() ? null : $this->security(SecurityStatus::forResource($resource, $scans, $imageStatuses)),
|
||||
'images' => $scans->map(fn (ImageScan $scan): array => [
|
||||
'scan_uuid' => (string) $scan->uuid,
|
||||
'owner_uuid' => (string) $scan->resource?->getAttribute('uuid'),
|
||||
'image_ref' => (string) $scan->image_ref,
|
||||
'scan_status' => $scan->status?->value,
|
||||
'error_code' => $scan->error_code?->value,
|
||||
'scanned_at' => $scan->scanned_at?->toIso8601String(),
|
||||
'truncated' => (bool) data_get($scan->summary, 'truncated', false),
|
||||
'security_status' => $imageStatuses[(string) $scan->uuid]->status->value,
|
||||
])->values(),
|
||||
'findings' => array_slice($findings, ($page - 1) * $perPage, $perPage),
|
||||
'pagination' => ['page' => $page, 'per_page' => $perPage, 'total' => count($findings)],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The findings of the current scans, worst first, in the same fix states as the UI.
|
||||
*
|
||||
* @param Collection<int, ImageScan> $scans
|
||||
* @param Closure(Model): RelevanceRules $rulesFor
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function findings(Collection $scans, Closure $rulesFor): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($scans as $scan) {
|
||||
$owner = $scan->resource;
|
||||
$rules = $rulesFor($owner);
|
||||
$classify = SecurityActions::fixStates($scan, $rules);
|
||||
|
||||
foreach (ResourceScans::findings($scan) as $finding) {
|
||||
$ignore = $rules->ignores->matches($finding);
|
||||
$rows[] = $this->finding($finding, $ignore === null ? $classify($finding)->value : 'ignored', $ignore) + [
|
||||
'image_ref' => (string) $scan->image_ref,
|
||||
'owner_uuid' => (string) $owner->getAttribute('uuid'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($rows, fn (array $a, array $b): int => [$b['kev'], self::SEVERITY_RANK[$b['severity']], $b['risk'] ?? 0.0]
|
||||
<=> [$a['kev'], self::SEVERITY_RANK[$a['severity']], $a['risk'] ?? 0.0]);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function finding(Finding $finding, string $state, ?ImageScanIgnore $ignore): array
|
||||
{
|
||||
return [
|
||||
'id' => $finding->id,
|
||||
'display_id' => $finding->displayId,
|
||||
'aliases' => $finding->aliases,
|
||||
'title' => $finding->title,
|
||||
'severity' => TeamVulnerabilities::severityOf($finding),
|
||||
'package' => $finding->package,
|
||||
'version' => $finding->version,
|
||||
'package_type' => $finding->type,
|
||||
'fixed_in' => $finding->fixedIn,
|
||||
'state' => $state,
|
||||
'ignore' => $ignore === null ? null : [
|
||||
'reason' => $ignore->reason,
|
||||
'expires_at' => $ignore->expires_at?->toIso8601String(),
|
||||
'team_wide' => $ignore->resource_type === null,
|
||||
],
|
||||
'kev' => $finding->kev,
|
||||
'epss' => $finding->epss,
|
||||
'risk' => $finding->risk,
|
||||
'cvss_score' => $finding->cvssScore,
|
||||
'advisory_url' => ResourceScans::advisoryUrl($finding->displayId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The security status without the internal facts and record IDs.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function security(ResourceSecurity $security): array
|
||||
{
|
||||
return [
|
||||
'status' => $security->status->value,
|
||||
'label' => TeamVulnerabilities::statusLabel($security->status),
|
||||
'action' => TeamVulnerabilities::action($security)['text'] ?? null,
|
||||
'check_reason' => $security->checkReason,
|
||||
'problem_count' => $security->problemCount,
|
||||
'exploited_count' => $security->exploitedCount,
|
||||
'any_exploited' => $security->anyExploited,
|
||||
'critical_count' => $security->criticalCount,
|
||||
'high_count' => $security->highCount,
|
||||
'medium_count' => $security->mediumCount,
|
||||
'eol' => $security->eol,
|
||||
'checked_at' => $security->checkedAt?->toIso8601String(),
|
||||
'actions' => array_map(fn (SecurityAction $action): array => [
|
||||
'type' => $action->type->value,
|
||||
'sentence' => $action->sentence,
|
||||
'target' => $action->target,
|
||||
'copy' => $action->copy,
|
||||
'image' => $action->image,
|
||||
'ref' => $action->ref,
|
||||
], $security->actions),
|
||||
];
|
||||
}
|
||||
|
||||
private function resourceType(Model $resource): string
|
||||
{
|
||||
return match (true) {
|
||||
$resource instanceof Application => 'application',
|
||||
$resource instanceof Service => 'service',
|
||||
default => 'database',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A comma-separated query parameter, checked against the allowed values. Empty when not sent.
|
||||
*
|
||||
* @param list<string> $allowed
|
||||
* @return list<string>|JsonResponse
|
||||
*/
|
||||
private function listParameter(Request $request, string $name, array $allowed): array|JsonResponse
|
||||
{
|
||||
$raw = $request->query($name);
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
if (! is_string($raw)) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => [$name => ["The {$name} must be a comma-separated string."]]], 422);
|
||||
}
|
||||
|
||||
$values = array_values(array_unique(array_filter(array_map('trim', explode(',', strtolower($raw))))));
|
||||
$unknown = array_diff($values, $allowed);
|
||||
if ($unknown !== []) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => [$name => ['Allowed values: '.implode(', ', $allowed).'.']]], 422);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,12 @@ class Controller extends BaseController
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
])->save();
|
||||
]);
|
||||
// Only an emailed token proves the address. A copied link can be used by the inviting admin.
|
||||
if ($invitation->via === 'email' && ! $user->hasVerifiedEmail()) {
|
||||
$user->forceFill(['email_verified_at' => now()]);
|
||||
}
|
||||
$user->save();
|
||||
$invitation->delete();
|
||||
|
||||
return [$user, $team, $invitation, $alreadyMember];
|
||||
@@ -224,6 +229,11 @@ class Controller extends BaseController
|
||||
abort(400, 'You are not allowed to accept this invitation.');
|
||||
}
|
||||
|
||||
// A magic link session accepts only the invitation of its own link, until the user sets a password.
|
||||
if ($user->force_password_reset) {
|
||||
abort(400, 'Set your password before you accept another invitation.');
|
||||
}
|
||||
|
||||
if (! $invitation->isValid()) {
|
||||
abort(400, 'Invitation expired.');
|
||||
}
|
||||
@@ -249,6 +259,11 @@ class Controller extends BaseController
|
||||
abort(400, 'You are not allowed to accept this invitation.');
|
||||
}
|
||||
|
||||
// A magic link session accepts only the invitation of its own link, until the user sets a password.
|
||||
if ($user->force_password_reset) {
|
||||
abort(400, 'Set your password before you accept another invitation.');
|
||||
}
|
||||
|
||||
if (! $invitation->isValid()) {
|
||||
abort(400, 'Invitation expired.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ImageScan;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use App\Services\ImageScan\SbomDocument;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Downloads the SBOM of a current scan of a resource, made from the stored package list only (spec 9).
|
||||
*/
|
||||
class ImageScanSbomController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$scanUuid = (string) $request->route('scan_uuid');
|
||||
$format = (string) $request->route('format');
|
||||
abort_unless(in_array($format, SbomDocument::FORMATS, true), 404);
|
||||
|
||||
[$type, $uuid] = match (true) {
|
||||
$request->route('application_uuid') !== null => ['application', (string) $request->route('application_uuid')],
|
||||
$request->route('service_uuid') !== null => ['service', (string) $request->route('service_uuid')],
|
||||
default => ['database', (string) $request->route('database_uuid')],
|
||||
};
|
||||
|
||||
$user = $request->user();
|
||||
$resource = ResourceScans::find($type, $uuid, $user->teams->pluck('id')->all());
|
||||
abort_unless($resource !== null && data_get($resource, 'environment.uuid') === $request->route('environment_uuid'), 404);
|
||||
Gate::authorize('view', $resource);
|
||||
|
||||
$scan = ResourceScans::current($resource)->first(fn (ImageScan $scan): bool => $scan->uuid === $scanUuid);
|
||||
$sbom = $scan?->sbom;
|
||||
abort_unless($scan !== null && $sbom !== null && $sbom->server_id === $scan->server_id, 404);
|
||||
|
||||
$document = $format === 'cyclonedx' ? SbomDocument::cycloneDx($scan, $sbom) : SbomDocument::spdx($scan, $sbom);
|
||||
$name = Str::slug((string) $resource->getAttribute('name').'-'.$scan->image_ref) ?: 'image';
|
||||
$extension = $format === 'cyclonedx' ? 'cdx.json' : 'spdx.json';
|
||||
|
||||
return response()->json($document, 200, [
|
||||
'Content-Disposition' => 'attachment; filename="'.$name.'-sbom.'.$extension.'"',
|
||||
], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class Gitea extends Controller
|
||||
|
||||
// Only users who may administer the source can complete OAuth and store tokens.
|
||||
if (! $request->user()->can('update', $giteaApp)) {
|
||||
return redirect()->route('source.all')->with('error', 'You are not authorized to connect this Gitea App.');
|
||||
return redirect()->route('source.all')->with('error', 'You are not authorized to connect this Gitea / Forgejo App.');
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($giteaApp->html_url, '/');
|
||||
@@ -85,7 +85,7 @@ class Gitea extends Controller
|
||||
{
|
||||
try {
|
||||
$return_payloads = collect([]);
|
||||
$x_gitea_event = Str::lower((string) $request->header('X-Gitea-Event'));
|
||||
$x_gitea_event = Str::lower((string) ($request->header('X-Gitea-Event') ?? $request->header('X-Forgejo-Event')));
|
||||
|
||||
if ($x_gitea_event === 'ping') {
|
||||
return response('pong');
|
||||
@@ -98,7 +98,7 @@ class Gitea extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$signature = (string) $request->header('X-Gitea-Signature');
|
||||
$signature = (string) ($request->header('X-Gitea-Signature') ?? $request->header('X-Forgejo-Signature'));
|
||||
if ($signature === '') {
|
||||
$signature = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
|
||||
}
|
||||
@@ -365,8 +365,8 @@ class Gitea extends Controller
|
||||
{
|
||||
try {
|
||||
$return_payloads = collect([]);
|
||||
$x_gitea_delivery = request()->header('X-Gitea-Delivery');
|
||||
$x_gitea_event = Str::lower((string) $request->header('X-Gitea-Event'));
|
||||
$x_gitea_delivery = $request->header('X-Gitea-Delivery') ?? $request->header('X-Forgejo-Delivery');
|
||||
$x_gitea_event = Str::lower((string) ($request->header('X-Gitea-Event') ?? $request->header('X-Forgejo-Event')));
|
||||
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
|
||||
$content_type = $request->header('Content-Type');
|
||||
$payload = $request->collect();
|
||||
|
||||
@@ -878,6 +878,7 @@ class Github extends Controller
|
||||
$github_app = GithubApp::whereKey(data_get($payload, 'github_app_id'))
|
||||
->where('team_id', $team_id)
|
||||
->firstOrFail();
|
||||
abort_unless($request->user()->can('update', $github_app), 403);
|
||||
|
||||
if ($request->user()->currentTeam()?->id !== $team_id) {
|
||||
refreshSession($github_app->team);
|
||||
|
||||
@@ -22,6 +22,7 @@ class ApiAbility extends CheckForAnyAbility
|
||||
'deployments',
|
||||
'resources',
|
||||
'version',
|
||||
'vulnerabilities',
|
||||
];
|
||||
|
||||
public const SCOPED_ACCESS_DENIED_MESSAGE = 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.';
|
||||
|
||||
@@ -24,6 +24,7 @@ use App\Models\SwarmDocker;
|
||||
use App\Notifications\Application\DeploymentFailed;
|
||||
use App\Notifications\Application\DeploymentSuccess;
|
||||
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
|
||||
use App\Services\ImageScan\DeploymentImageScan;
|
||||
use App\Support\RemoteSecretReferences;
|
||||
use App\Support\RemoteSecretValueFormatter;
|
||||
use App\Support\ValidationPatterns;
|
||||
@@ -43,6 +44,7 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
@@ -54,6 +56,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
|
||||
|
||||
/**
|
||||
* Logged when a git command of Coolify fails because of the SSH host key. The deployment page looks for this text.
|
||||
*/
|
||||
public const GIT_HOST_KEY_CHANGED_HINT = 'The Git host sent an SSH host key that is not the saved or published key. This can be an attack. Ask the administrator of the Git host if the key changed. Then open Source > SSH host keys, click Fetch current host key, and accept the key only if its fingerprint is the same as the fingerprint from the administrator. For GitHub and Bitbucket Cloud, Fetch current host key also reads the keys that they publish. For GitLab.com, compare the fingerprint with the keys on docs.gitlab.com.';
|
||||
|
||||
private const BUILD_FINGERPRINT_LABEL = 'coolify.build-fingerprint';
|
||||
|
||||
public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env';
|
||||
@@ -121,6 +128,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public $timeout = 3600;
|
||||
|
||||
/** Unix time when the worker started handle(); the timeout counts from it. */
|
||||
private ?int $handleStartedAt = null;
|
||||
|
||||
public static int $batch_counter = 0;
|
||||
|
||||
private ApplicationDeploymentQueue $application_deployment_queue;
|
||||
@@ -271,6 +281,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private Collection|string $build_secrets;
|
||||
|
||||
private ?DeploymentImageScan $imageScan = null;
|
||||
|
||||
/**
|
||||
* Build-time variables that the helper container has in its environment for build secrets.
|
||||
*
|
||||
@@ -436,6 +448,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->handleStartedAt = now()->getTimestamp();
|
||||
|
||||
// Check if deployment was cancelled before we even started
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
@@ -709,6 +723,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Mark deployment as complete FIRST, before any other operations
|
||||
// This ensures the deployment status is FINISHED even if subsequent operations fail
|
||||
$this->completeDeployment();
|
||||
$this->imageScan()->linkReusedScans();
|
||||
|
||||
// Then handle side effects - these should not fail the deployment
|
||||
try {
|
||||
@@ -742,6 +757,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
$this->imageScan()->dispatchScanAfterStart();
|
||||
$this->imageScan()->refreshSecurity();
|
||||
}
|
||||
|
||||
private function deploy_simple_dockerfile()
|
||||
@@ -773,6 +790,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->generate_build_env_variables();
|
||||
$this->add_build_env_variables_to_dockerfile();
|
||||
$this->build_image();
|
||||
$this->imageScan()->rememberDockerfileBaseImage();
|
||||
|
||||
// Save runtime environment variables AFTER the build
|
||||
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
||||
@@ -917,6 +935,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->generate_build_env_variables();
|
||||
|
||||
$this->loadComposeFileForDeployment();
|
||||
$this->imageScan()->forgetStaleComposeImageOverrides();
|
||||
$buildEnvironment = null;
|
||||
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
||||
$this->application->oldRawParser();
|
||||
@@ -970,6 +989,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->save_runtime_environment_variables();
|
||||
|
||||
$this->pull_docker_compose_images();
|
||||
$this->imageScan()->applyComposeStartPins();
|
||||
|
||||
$this->imageScan()->checkComposeGate();
|
||||
|
||||
$this->startDeployPhase();
|
||||
$this->stop_running_container(force: true);
|
||||
@@ -1396,6 +1418,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->generate_build_env_variables();
|
||||
$this->add_build_env_variables_to_dockerfile();
|
||||
$this->build_image();
|
||||
$this->imageScan()->rememberDockerfileBaseImage();
|
||||
|
||||
// Save runtime environment variables AFTER the build
|
||||
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
||||
@@ -1724,6 +1747,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
$this->should_skip_build();
|
||||
$this->completeDeployment();
|
||||
$this->imageScan()->linkReusedScans();
|
||||
}
|
||||
|
||||
private function should_skip_build()
|
||||
@@ -2633,11 +2657,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
try {
|
||||
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
|
||||
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
|
||||
throw new InvalidArgumentException('Invalid build-time environment variable key.');
|
||||
}
|
||||
|
||||
return $key;
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin);
|
||||
|
||||
// The log already explains the problem and how to fix it.
|
||||
@@ -2705,6 +2729,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
private function ensureRegistryImageIsNotShared(): void
|
||||
{
|
||||
if ($this->restart_only || $this->is_this_additional_server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$otherApplication = $this->application->otherApplicationBuildingSameImage();
|
||||
if ($otherApplication) {
|
||||
throw new DeploymentException($this->application->sharedImageNameMessage($otherApplication));
|
||||
@@ -3011,6 +3039,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function rolling_update()
|
||||
{
|
||||
$this->imageScan()->applyStartPin();
|
||||
$this->imageScan()->checkGate($this->production_image_name);
|
||||
|
||||
try {
|
||||
$this->startDeployPhase();
|
||||
$this->write_compose_file_to_workdir();
|
||||
@@ -3065,6 +3096,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The image scan steps of this deployment (gate, start pins, scan after the start), created on first use. It reads
|
||||
* job properties when a step needs them, because the job changes servers and the Compose file during the deploy.
|
||||
*/
|
||||
private function imageScan(): DeploymentImageScan
|
||||
{
|
||||
return $this->imageScan ??= new DeploymentImageScan(
|
||||
$this->application,
|
||||
$this->application_deployment_queue,
|
||||
read: fn (string $property): mixed => match ($property) {
|
||||
'server', 'mainServer', 'use_build_server', 'deployment_uuid', 'pull_request_id', 'commit', 'rollback',
|
||||
'skip_build', 'is_this_additional_server', 'container_name', 'docker_compose', 'generated_docker_compose',
|
||||
'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'workdir',
|
||||
'docker_compose_location', 'saved_outputs', 'timeout' => $this->{$property},
|
||||
// Unix time when the worker started the job; null when handle() did not run.
|
||||
'started_at' => $this->handleStartedAt,
|
||||
// Not set before the build server is chosen or the image is named.
|
||||
'build_server', 'production_image_name' => $this->{$property} ?? null,
|
||||
},
|
||||
write: fn (string $property, string $value): string => match ($property) {
|
||||
'docker_compose', 'generated_docker_compose' => $this->{$property} = $value,
|
||||
},
|
||||
run: fn (array ...$commands) => $this->execute_remote_command(...$commands),
|
||||
checkForCancellation: fn () => $this->checkForCancellation(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The proxy adds the new container some seconds after it is healthy. Stopping the old
|
||||
* container before that sends requests to a closed port (502), see #8627.
|
||||
@@ -3256,6 +3314,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->add_build_env_variables_to_dockerfile();
|
||||
}
|
||||
$this->build_pull_request_image();
|
||||
$this->imageScan()->rememberDockerfileBaseImage();
|
||||
|
||||
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
||||
$this->save_runtime_environment_variables();
|
||||
@@ -3376,6 +3435,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
[
|
||||
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->basedir}"),
|
||||
],
|
||||
[
|
||||
'command' => executeInDocker($this->deployment_uuid, gitSshSetupCommand($this->deployment_uuid, $this->application->git_ssh_known_hosts)),
|
||||
'hidden' => true,
|
||||
],
|
||||
);
|
||||
$this->run_pre_deployment_command();
|
||||
}
|
||||
@@ -3546,7 +3609,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
|
||||
{
|
||||
$sshCommand = gitSshCommand($this->customPort, $identityFile);
|
||||
$sshCommand = gitSshCommand($this->deployment_uuid, $this->customPort, $identityFile);
|
||||
|
||||
$gitConfigOption = $this->application->gitHttpsCredentialConfigOption();
|
||||
$gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git';
|
||||
@@ -3590,36 +3653,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
$private_key = data_get($this->application, 'private_key.private_key');
|
||||
if ($private_key) {
|
||||
$private_key = base64_encode($private_key);
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}";
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
'hidden' => true,
|
||||
'skip_command_log' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)),
|
||||
'hidden' => true,
|
||||
'save' => 'git_commit_sha',
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
|
||||
'hidden' => true,
|
||||
'save' => 'git_commit_sha',
|
||||
],
|
||||
);
|
||||
}
|
||||
$this->withGitHostKeyHint(function () use ($private_key, $lsRemoteRef): void {
|
||||
if ($private_key) {
|
||||
$private_key = base64_encode($private_key);
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}";
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
'hidden' => true,
|
||||
'skip_command_log' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)),
|
||||
'hidden' => true,
|
||||
'save' => 'git_commit_sha',
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
|
||||
'hidden' => true,
|
||||
'save' => 'git_commit_sha',
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
$this->save_git_ssh_known_hosts();
|
||||
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) {
|
||||
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
|
||||
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
|
||||
@@ -3719,7 +3785,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
|
||||
}
|
||||
$this->import_repository();
|
||||
$this->withGitHostKeyHint(fn () => $this->import_repository());
|
||||
$this->save_git_ssh_known_hosts();
|
||||
$this->create_workdir();
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
@@ -3737,6 +3804,79 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the host key hint when a git command of Coolify fails because of the SSH host key. The hint is logged
|
||||
* here and not in failed(), because a git command in the build (for example a Dockerfile RUN step) can fail the same way.
|
||||
*/
|
||||
private function withGitHostKeyHint(callable $runGitCommands): void
|
||||
{
|
||||
try {
|
||||
$runGitCommands();
|
||||
} catch (Throwable $e) {
|
||||
if (str_contains($e->getMessage(), 'Host key verification failed')) {
|
||||
$this->application_deployment_queue->addLogEntry(self::GIT_HOST_KEY_CHANGED_HINT, 'stderr');
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the host keys that SSH accepted on first use, so later deployments reject a changed key.
|
||||
*/
|
||||
private function save_git_ssh_known_hosts(): void
|
||||
{
|
||||
$this->saved_outputs->forget('git_ssh_known_hosts');
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, 'cat '.gitKnownHostsFile($this->deployment_uuid)),
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
'save' => 'git_ssh_known_hosts',
|
||||
]);
|
||||
// Two first deployments can accept different keys at the same time; keep the key that was saved first.
|
||||
$hostAndKeyType = fn (string $line): string => implode(' ', array_slice(explode(' ', $line), 0, 2));
|
||||
$savedHostsAndKeyTypes = array_map($hostAndKeyType, gitKnownHostsLines($this->application->git_ssh_known_hosts));
|
||||
// The file also has the built-in keys (gitProviderHostKeys()); they are not saved for the application.
|
||||
$newLines = array_values(array_filter(
|
||||
array_diff(gitKnownHostsLines((string) $this->saved_outputs->get('git_ssh_known_hosts')), gitProviderHostKeys()),
|
||||
fn (string $line) => ! in_array($hostAndKeyType($line), $savedHostsAndKeyTypes, true),
|
||||
));
|
||||
if ($newLines === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$addedLines = [];
|
||||
$conflictingLines = [];
|
||||
$knownHosts = $this->application->getConnection()->transaction(function () use ($newLines, $hostAndKeyType, &$addedLines, &$conflictingLines): string {
|
||||
$currentLines = gitKnownHostsLines(
|
||||
Application::query()->withoutGlobalScope('withRelations')->whereKey($this->application->id)->lockForUpdate()->value('git_ssh_known_hosts')
|
||||
);
|
||||
$savedHostsAndKeyTypes = array_map($hostAndKeyType, $currentLines);
|
||||
foreach (array_diff($newLines, $currentLines) as $line) {
|
||||
if (in_array($hostAndKeyType($line), $savedHostsAndKeyTypes, true)) {
|
||||
$conflictingLines[] = $line;
|
||||
} else {
|
||||
$addedLines[] = $line;
|
||||
}
|
||||
}
|
||||
$merged = implode("\n", [...$currentLines, ...$addedLines]);
|
||||
if ($addedLines !== []) {
|
||||
Application::query()->whereKey($this->application->id)->update(['git_ssh_known_hosts' => $merged]);
|
||||
}
|
||||
|
||||
return $merged;
|
||||
});
|
||||
$this->application->forceFill(['git_ssh_known_hosts' => $knownHosts])->syncOriginalAttribute('git_ssh_known_hosts');
|
||||
foreach ($addedLines as $line) {
|
||||
[$host, $keyType] = explode(' ', $line);
|
||||
$this->application_deployment_queue->addLogEntry("Saved the SSH host key of {$host} ({$keyType}). Later deployments accept only this key. You can see or remove it under Source.");
|
||||
}
|
||||
foreach ($conflictingLines as $line) {
|
||||
[$host, $keyType] = explode(' ', $line);
|
||||
$this->application_deployment_queue->addLogEntry("Coolify did not save the SSH host key of {$host} ({$keyType}). Another deployment saved a different key of this type at the same time. Later deployments accept only the saved key.", 'stderr');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSH exits with 255 when the connection drops, which can happen during a long clone. Git exits with
|
||||
* 128 for its own errors, so only 255 is retried. The interrupted clone can still run in the helper
|
||||
@@ -5522,15 +5662,23 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
);
|
||||
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->composeProjectName()} --project-directory {$this->workdir} pull"), 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
|
||||
);
|
||||
$up = [executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --project-directory {$this->workdir} up --build -d"), 'hidden' => true];
|
||||
if ($this->imageScan()->pinnedImage() !== null || $this->imageScan()->startPinned()) {
|
||||
// The gate pulled and scanned the pinned image.
|
||||
$this->execute_remote_command($up);
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->composeProjectName()} --project-directory {$this->workdir} pull"), 'hidden' => true],
|
||||
$up,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if ($this->use_build_server) {
|
||||
// A pinned digest cannot change in the registry, so Compose pulls it only when it is missing.
|
||||
$pull = $this->imageScan()->pinnedImage() !== null ? '' : '--pull always ';
|
||||
$this->execute_remote_command(
|
||||
[serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
|
||||
[serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up {$pull}--build -d", 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
@@ -6712,6 +6860,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$this->failDeployment();
|
||||
// A failed remote command can store the failed status with a query update, which fires no model event.
|
||||
try {
|
||||
SyncGithubDeploymentJob::dispatchFor($this->application_deployment_queue->refresh());
|
||||
} catch (Throwable $e) {
|
||||
$this->logStatusTransitionSideEffectFailure('Queueing the GitHub deployment sync failed', $e);
|
||||
}
|
||||
|
||||
// failed() runs on a fresh job instance, so remote secrets fetched during the deployment are not loaded here.
|
||||
// Every entry still passes through ApplicationDeploymentQueue::redactLogText(); raw stack traces are not written
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Jobs;
|
||||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
@@ -44,20 +45,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
match ($this->status) {
|
||||
ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
|
||||
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
|
||||
ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".$this->getPreviewLinks(),
|
||||
ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
|
||||
ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
|
||||
ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
|
||||
ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
|
||||
};
|
||||
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
|
||||
$application_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/logs";
|
||||
|
||||
$this->body .= '[Open Build Logs]('.$this->build_logs_url.') | [Open Application Logs]('.$application_logs_url.")\n\n\n";
|
||||
$this->body .= 'Last updated at: '.now()->toDateTimeString().' CET';
|
||||
$this->body = $this->commentBody($serviceName);
|
||||
if ($this->preview->pull_request_issue_comment_id) {
|
||||
$this->update_comment();
|
||||
} else {
|
||||
@@ -68,6 +56,55 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function commentBody(string $serviceName): string
|
||||
{
|
||||
$body = match ($this->status) {
|
||||
ProcessStatus::QUEUED => "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
|
||||
ProcessStatus::IN_PROGRESS => "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
|
||||
ProcessStatus::FINISHED => "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".$this->getPreviewLinks(),
|
||||
ProcessStatus::ERROR => "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
|
||||
ProcessStatus::KILLED => "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
|
||||
ProcessStatus::CANCELLED => "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
|
||||
ProcessStatus::CLOSED => '', // Already handled in handle(), but included for completeness
|
||||
};
|
||||
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
|
||||
$application_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/logs";
|
||||
|
||||
$body .= $this->imageScanLine();
|
||||
$body .= '[Open Build Logs]('.$this->build_logs_url.') | [Open Application Logs]('.$application_logs_url.")\n\n\n";
|
||||
$body .= 'Last updated at: '.now()->toDateTimeString().' CET';
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Image scan: passed" or "Image scan: blocked" with a link to the deployment. PR comments can be
|
||||
* public, so it never shows counts or vulnerability IDs. Empty when the gate did not scan.
|
||||
*/
|
||||
private function imageScanLine(): string
|
||||
{
|
||||
if (! $this->deployment_uuid) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::query()
|
||||
->where('deployment_uuid', $this->deployment_uuid)
|
||||
->where('application_id', $this->application->id)
|
||||
->first(['id', 'scan_gate_result']);
|
||||
if (! $deployment || ! is_array($deployment->scan_gate_result)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($deployment->isImageScanBlocked()) {
|
||||
return 'Image scan: blocked. [Open in Coolify]('.$this->build_logs_url.")\n\n";
|
||||
}
|
||||
if (data_get($deployment->scan_gate_result, 'scan_ran') === true) {
|
||||
return 'Image scan: passed. [Open in Coolify]('.$this->build_logs_url.")\n\n";
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function update_comment()
|
||||
{
|
||||
['data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'patch', data: [
|
||||
|
||||
@@ -65,9 +65,9 @@ class CheckAndStartSentinelJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
|
||||
}
|
||||
|
||||
// One SSH call reads the version and the last push result. Sentinel versions without
|
||||
// /api/push-status give an empty second line.
|
||||
// /api/push-status give an empty second line. printf is a shell builtin, so the token is not a process argument.
|
||||
$output = instant_remote_process_with_timeout([
|
||||
"docker exec coolify-sentinel sh -c 'curl -s http://127.0.0.1:8888/api/version; echo; curl -sf -H \"Authorization: Bearer \$TOKEN\" http://127.0.0.1:8888/api/push-status'",
|
||||
"docker exec coolify-sentinel sh -c 'curl -s http://127.0.0.1:8888/api/version; echo; printf \"Authorization: Bearer %s\\n\" \"\$TOKEN\" | curl -sf -H @- http://127.0.0.1:8888/api/push-status'",
|
||||
], $this->server, false);
|
||||
[$runningVersion, $pushStatus] = array_pad(explode("\n", trim((string) $output), 2), 2, null);
|
||||
$this->rememberPushError($pushStatus);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\AutoUpdateNotifier;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -31,6 +32,7 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if (isDev() || isCloud()) {
|
||||
return false;
|
||||
}
|
||||
AutoUpdateNotifier::checkResult();
|
||||
$versions = refresh_versions_file($this->cdnTries);
|
||||
if ($versions === null) {
|
||||
return false;
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Services\RcloneTransfer;
|
||||
use App\Support\ResourceStartActivity;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
@@ -13,7 +17,10 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
@@ -34,6 +41,12 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
|
||||
return $this->server->uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* The job that starts an rclone container creates its execution or activity before it, except a database
|
||||
* import, which creates its activity right after it. Younger containers are kept.
|
||||
*/
|
||||
private const RCLONE_MIN_AGE_SECONDS = 900;
|
||||
|
||||
private static function helperContainersCommand(): string
|
||||
{
|
||||
return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\'';
|
||||
@@ -47,48 +60,114 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false);
|
||||
$helperContainers = collect(json_decode((string) $containers, true) ?: [])
|
||||
->filter(fn (mixed $container): bool => is_array($container) && filled(data_get($container, 'ID')) && filled(data_get($container, 'Names')));
|
||||
|
||||
if ($helperContainers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$finishedDeploymentUuids = ApplicationDeploymentQueue::query()
|
||||
->whereIn('deployment_uuid', $helperContainers->pluck('Names')->all())
|
||||
->whereNotIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])
|
||||
->pluck('deployment_uuid')
|
||||
->all();
|
||||
|
||||
$runningBackupUploadNames = ScheduledDatabaseBackupExecution::query()
|
||||
->whereIn('uuid', $helperContainers->pluck('Names')->map(fn (string $name): string => Str::after($name, 'backup-of-'))->all())
|
||||
->where('status', 'running')
|
||||
->pluck('uuid')
|
||||
->map(fn (string $uuid): string => "backup-of-{$uuid}")
|
||||
->all();
|
||||
|
||||
foreach ($helperContainers as $container) {
|
||||
$isFinishedDeployment = in_array($container['Names'], $finishedDeploymentUuids, true);
|
||||
// A worker that is killed during the upload never removes its upload container.
|
||||
$isOrphanedBackupUpload = str_starts_with($container['Names'], 'backup-of-')
|
||||
&& ! in_array($container['Names'], $runningBackupUploadNames, true);
|
||||
if (! $isFinishedDeployment && ! $isOrphanedBackupUpload) {
|
||||
continue;
|
||||
}
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
|
||||
'container' => $container['Names'],
|
||||
'id' => $container['ID'],
|
||||
]);
|
||||
|
||||
instant_remote_process_with_timeout(['docker container rm -f '.escapeshellarg($container['ID'])], $this->server, false);
|
||||
}
|
||||
$activeTransfers = $this->activeTransferOwners();
|
||||
$this->removeOrphanedHelperContainers($activeTransfers);
|
||||
$this->removeOrphanedRcloneContainers($activeTransfers);
|
||||
} catch (\Throwable $e) {
|
||||
send_internal_notification('CleanupHelperContainersJob failed with error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, string> $activeTransfers
|
||||
*/
|
||||
private function removeOrphanedHelperContainers(Collection $activeTransfers): void
|
||||
{
|
||||
$containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false);
|
||||
$helperContainers = collect(json_decode((string) $containers, true) ?: [])
|
||||
->filter(fn (mixed $container): bool => is_array($container) && filled(data_get($container, 'ID')) && filled(data_get($container, 'Names')));
|
||||
|
||||
if ($helperContainers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$finishedDeploymentUuids = ApplicationDeploymentQueue::query()
|
||||
->whereIn('deployment_uuid', $helperContainers->pluck('Names')->all())
|
||||
->whereNotIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])
|
||||
->pluck('deployment_uuid')
|
||||
->all();
|
||||
|
||||
foreach ($helperContainers as $container) {
|
||||
$isFinishedDeployment = in_array($container['Names'], $finishedDeploymentUuids, true);
|
||||
// A worker that is killed during the upload never removes its upload container.
|
||||
$isOrphanedBackupUpload = str_starts_with($container['Names'], 'backup-of-')
|
||||
&& ! $activeTransfers->contains(Str::after($container['Names'], 'backup-of-'));
|
||||
if (! $isFinishedDeployment && ! $isOrphanedBackupUpload) {
|
||||
continue;
|
||||
}
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
|
||||
'container' => $container['Names'],
|
||||
'id' => $container['ID'],
|
||||
]);
|
||||
|
||||
instant_remote_process_with_timeout(['docker container rm -f '.escapeshellarg($container['ID'])], $this->server, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An rclone container has the storage config with its password or SSH key. It stays until the end of its sleep
|
||||
* when a worker timeout kills the backup or import job before the job removes it.
|
||||
*
|
||||
* @param Collection<int, string> $activeTransfers
|
||||
*/
|
||||
private function removeOrphanedRcloneContainers(Collection $activeTransfers): void
|
||||
{
|
||||
$output = instant_remote_process_with_timeout([
|
||||
'docker container ps --filter label='.RcloneTransfer::LABEL." --format '{{json .}}'",
|
||||
], $this->server, false);
|
||||
$containers = collect(explode("\n", (string) $output))
|
||||
->map(fn (string $line): mixed => json_decode(trim($line), true))
|
||||
->filter(fn (mixed $container): bool => is_array($container) && filled($container['ID'] ?? null));
|
||||
if ($containers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($containers as $container) {
|
||||
$name = (string) ($container['Names'] ?? '');
|
||||
$createdAt = Carbon::createFromFormat('Y-m-d H:i:s O', substr((string) ($container['CreatedAt'] ?? ''), 0, 25));
|
||||
if (! $createdAt || $createdAt->gt(now()->subSeconds(self::RCLONE_MIN_AGE_SECONDS))) {
|
||||
continue;
|
||||
}
|
||||
if (self::belongsToActiveTransfer($name, $activeTransfers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Removing orphaned rclone container', ['container' => $name]);
|
||||
instant_remote_process_with_timeout(['docker container rm -f '.escapeshellarg((string) $container['ID'])], $this->server, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, string> $activeTransfers
|
||||
*/
|
||||
private static function belongsToActiveTransfer(string $containerName, Collection $activeTransfers): bool
|
||||
{
|
||||
return preg_match('/^(backup-of|volume-backup|volume-upload|s3-restore|backup-decompress)-(.+)$/', $containerName, $matches) === 1
|
||||
&& $activeTransfers->contains($matches[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* UUIDs of the running backups and database imports. Their upload and restore containers (helper or rclone) are
|
||||
* named "backup-of-", "volume-backup-", "volume-upload-", "s3-restore-", or "backup-decompress-" and the UUID.
|
||||
*
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
private function activeTransferOwners(): Collection
|
||||
{
|
||||
$imports = Activity::query()
|
||||
->where('properties->operation', ResourceStartActivity::DATABASE_IMPORT_OPERATION)
|
||||
->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value])
|
||||
->get()
|
||||
->map(fn (Activity $activity): string => (string) data_get($activity, 'properties.operation_uuid'));
|
||||
|
||||
return ScheduledDatabaseBackupExecution::query()->where('status', 'running')->pluck('uuid')
|
||||
->merge(ScheduledVolumeBackupExecution::query()->where('status', 'running')->pluck('uuid'))
|
||||
->merge($imports)
|
||||
->filter()
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GiteaApp;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CleanupUnusedSshKeys
|
||||
{
|
||||
public function handle(): void
|
||||
{
|
||||
// Read metadata only: cleanup must never decrypt key material.
|
||||
$keys = PrivateKey::query()->select(['id', 'uuid'])->get()->keyBy('uuid');
|
||||
$rotationStates = $keys->isEmpty() ? [] : Cache::many($keys->map(fn (PrivateKey $key) => RotatePrivateKeyJob::cacheKey($key))->values()->all());
|
||||
$rotatingIds = [];
|
||||
foreach ($keys as $key) {
|
||||
$state = $rotationStates[RotatePrivateKeyJob::cacheKey($key)] ?? null;
|
||||
if (is_array($state) && in_array($state['status'] ?? null, ['queued', 'running'], true)) {
|
||||
$rotatingIds[] = $key->id;
|
||||
$rotatingIds[] = $state['new_private_key_id'] ?? null;
|
||||
}
|
||||
}
|
||||
$keyIdsInUse = null;
|
||||
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
foreach ($disk->files() as $filename) {
|
||||
if (! preg_match('/\Assh_key@([a-zA-Z0-9_-]+)\z/', $filename, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Share the writer's local lock; leave the lock file in place.
|
||||
$lock = fopen($disk->path($filename).'.lock', 'c');
|
||||
if ($lock === false) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (! flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
continue;
|
||||
}
|
||||
clearstatcache(true, $disk->path($filename));
|
||||
if (! $disk->exists($filename) || $disk->lastModified($filename) >= now()->subDay()->timestamp) {
|
||||
continue;
|
||||
}
|
||||
$key = $keys->get($matches[1]);
|
||||
$keyIdsInUse ??= $this->keyIdsInUse();
|
||||
if ($key && (in_array($key->id, $rotatingIds) || isset($keyIdsInUse[$key->id]))) {
|
||||
continue;
|
||||
}
|
||||
$disk->delete($filename);
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of the keys that PrivateKey::isInUse() reports as used, as array keys.
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private function keyIdsInUse(): array
|
||||
{
|
||||
return collect([
|
||||
Server::query(),
|
||||
Application::query()->withoutGlobalScope('withRelations'),
|
||||
GithubApp::query(),
|
||||
GitlabApp::query(),
|
||||
GiteaApp::query(),
|
||||
])
|
||||
->flatMap(fn (Builder $query) => $query->whereNotNull('private_key_id')->distinct()->pluck('private_key_id'))
|
||||
->mapWithKeys(fn (int|string $id) => [(int) $id => true])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use App\Notifications\Database\BackupFailed;
|
||||
use App\Notifications\Database\BackupSuccess;
|
||||
use App\Notifications\Database\BackupSuccessWithS3Warning;
|
||||
use App\Notifications\ScheduledJobRecovered;
|
||||
use App\Services\RcloneTransfer;
|
||||
use App\Services\ScheduledJobDeliveryService;
|
||||
use App\Services\Security\SensitiveDataRedactor;
|
||||
use App\Support\BackupCompression;
|
||||
@@ -519,8 +520,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
if ($s3UploadError) {
|
||||
$message = $message
|
||||
? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
|
||||
: 'Warning: S3 upload failed: '.$s3UploadError;
|
||||
? $message."\n\nWarning: Storage upload failed: ".$s3UploadError
|
||||
: 'Warning: Storage upload failed: '.$s3UploadError;
|
||||
}
|
||||
|
||||
$this->backup_log->update([
|
||||
@@ -904,7 +905,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
|
||||
throw new \Exception('Backup storage configuration is missing or has been deleted (storage ID: '.($previousS3StorageId ?? 'null').'). Uploads to a backup storage are disabled for this schedule.');
|
||||
}
|
||||
|
||||
$failures = [];
|
||||
@@ -947,6 +948,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
} else {
|
||||
$network = $this->database->destination->network;
|
||||
}
|
||||
if (! $s3->isS3()) {
|
||||
$this->uploadWithRclone($s3, $network);
|
||||
|
||||
return;
|
||||
}
|
||||
$safeNetwork = escapeshellarg($network);
|
||||
|
||||
$fullImageName = $this->getFullImageName();
|
||||
@@ -981,6 +987,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the backup file to an SFTP, FTPS, or WebDAV storage. The caller removes the container.
|
||||
*/
|
||||
private function uploadWithRclone(S3Storage $storage, string $network): void
|
||||
{
|
||||
$transfer = new RcloneTransfer($storage, $this->server, "backup-of-{$this->backup_log_uuid}");
|
||||
$transfer->start([$this->backupMountSource().':'.$this->backup_location.':ro'], $network, $this->commandTimeout());
|
||||
$transfer->upload($this->backup_location, $this->backup_location, $this->commandTimeout());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the `temporary` mc alias in a helper container. The S3 keys go to mc through the stdin of the
|
||||
* SSH process, so they are not in the arguments of any process on Coolify or on the server.
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -29,10 +30,11 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
|
||||
try {
|
||||
$github_access_token = generateGithubJwt($this->github_app);
|
||||
|
||||
$response = Http::GitSource($this->github_app->api_url)->withHeaders([
|
||||
$github = Http::GitSource($this->github_app->api_url)->withHeaders([
|
||||
'Authorization' => "Bearer $github_access_token",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
])->get("{$this->github_app->api_url}/app");
|
||||
]);
|
||||
$response = $github->get("{$this->github_app->api_url}/app");
|
||||
|
||||
if (! $response->successful()) {
|
||||
assertGithubClockInSync($response);
|
||||
@@ -45,6 +47,7 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->github_app->contents = data_get($permissions, 'contents');
|
||||
$this->github_app->metadata = data_get($permissions, 'metadata');
|
||||
$this->github_app->pull_requests = data_get($permissions, 'pull_requests');
|
||||
$this->github_app->deployments = $this->approvedInstallationPermission($github, 'deployments');
|
||||
$this->github_app->administration = data_get($permissions, 'administration');
|
||||
$this->github_app->organization_self_hosted_runners = data_get($permissions, 'organization_self_hosted_runners');
|
||||
$this->github_app->actions = data_get($permissions, 'actions');
|
||||
@@ -58,4 +61,22 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /app returns the permissions the App requests. A new permission
|
||||
* counts only after the installation approves it.
|
||||
*/
|
||||
private function approvedInstallationPermission(PendingRequest $github, string $permission): ?string
|
||||
{
|
||||
if (! $this->github_app->installation_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $github->get("{$this->github_app->api_url}/app/installations/{$this->github_app->installation_id}");
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('Failed to fetch GitHub app installation permissions: '.$response->body());
|
||||
}
|
||||
|
||||
return data_get($response->json(), "permissions.{$permission}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\ImageScan\RefreshCandidates;
|
||||
use App\Actions\ImageScan\ScanImage;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Enums\ImageScanStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\ImageSbom;
|
||||
use App\Models\ImageScan;
|
||||
use App\Models\ImageScanIgnore;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Services\ImageScan\IgnoreMatcher;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use App\Services\ImageScan\ScanLockBusy;
|
||||
use App\Services\ImageScan\ScannerCommands;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Bus\UniqueLock;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Daily rescan of the images that run on a server (spec 4.5): updates the Grype database, runs Grype once per
|
||||
* running image ID, updates the scan rows of this server only, and applies the retention of spec 4.3.
|
||||
*/
|
||||
class ImageRescanJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/** Scans kept per resource besides the running image and the rollback list. */
|
||||
public const KEEP_LATEST_SCANS = 5;
|
||||
|
||||
public const SBOM_UNUSED_DAYS = 30;
|
||||
|
||||
/** SBOM files removed per run, so the command line stays short. The rest go the next day. */
|
||||
public const MAX_SBOM_DELETES = 500;
|
||||
|
||||
private const TIMEOUT_DB_UPDATE = 960;
|
||||
|
||||
private const TIMEOUT_DEFAULT = 120;
|
||||
|
||||
/** The failed-day count of a server is kept this long after the last failure. */
|
||||
private const FAILURE_STATE_DAYS = 7;
|
||||
|
||||
public $tries = ScanResourceImagesJob::MAX_ATTEMPTS;
|
||||
|
||||
/** An unexpected error fails the job; only a busy scan lock retries it. */
|
||||
public $maxExceptions = 1;
|
||||
|
||||
public $timeout = 36000;
|
||||
|
||||
public $uniqueFor = 36000;
|
||||
|
||||
public function __construct(public Server $server)
|
||||
{
|
||||
// Self-hosted Horizon works through 'high' first, so scans there would delay deployments.
|
||||
$this->onQueue(isCloud() ? maintenance_queue() : 'default');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->server->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanning is on for the server and allowed on the instance, and this instance can reach the server.
|
||||
*/
|
||||
public static function canRunOn(Server $server): bool
|
||||
{
|
||||
return (bool) $server->settings?->is_image_scan_enabled
|
||||
&& (bool) InstanceSettings::query()->whereKey(0)->value('is_image_scanning_allowed')
|
||||
&& ! $server->isTransferredAway()
|
||||
&& $server->isFunctional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the days in a row on which the rescan of the server failed, for the digest (spec 8). A failed day is
|
||||
* one on which the running containers could not be listed or no image could be scanned. Success resets it.
|
||||
*/
|
||||
public static function recordRescanResult(Server $server, bool $succeeded): void
|
||||
{
|
||||
$key = self::failuresCacheKey($server);
|
||||
if ($succeeded) {
|
||||
Cache::forget($key);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$state = Cache::get($key);
|
||||
$today = now()->toDateString();
|
||||
$state = match (true) {
|
||||
! is_array($state) => ['days' => 1, 'last' => $today, 'notified' => false],
|
||||
($state['last'] ?? null) === $today => $state,
|
||||
($state['last'] ?? null) === now()->subDay()->toDateString() => [...$state, 'days' => (int) ($state['days'] ?? 0) + 1, 'last' => $today],
|
||||
default => ['days' => 1, 'last' => $today, 'notified' => false],
|
||||
};
|
||||
Cache::put($key, $state, now()->addDays(self::FAILURE_STATE_DAYS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Days in a row on which the rescan of the server failed, 0 after a success.
|
||||
*/
|
||||
public static function failedDays(Server $server): int
|
||||
{
|
||||
$state = Cache::get(self::failuresCacheKey($server));
|
||||
|
||||
return is_array($state) ? (int) ($state['days'] ?? 0) : 0;
|
||||
}
|
||||
|
||||
public static function isFailureNotified(Server $server): bool
|
||||
{
|
||||
return (bool) data_get(Cache::get(self::failuresCacheKey($server)), 'notified', false);
|
||||
}
|
||||
|
||||
public static function markFailureNotified(Server $server): void
|
||||
{
|
||||
$state = Cache::get(self::failuresCacheKey($server));
|
||||
if (is_array($state)) {
|
||||
Cache::put(self::failuresCacheKey($server), [...$state, 'notified' => true], now()->addDays(self::FAILURE_STATE_DAYS));
|
||||
}
|
||||
}
|
||||
|
||||
private static function failuresCacheKey(Server $server): string
|
||||
{
|
||||
return "image-rescan:failures:{$server->id}";
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$server = $this->server->fresh(['settings']);
|
||||
if (! $server instanceof Server || ! self::canRunOn($server)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$commands = new ScannerCommands($server, Str::lower(Str::random(24)));
|
||||
// Never sleeps on the scan lock, so a deploy gate gets it first. A busy lock runs the job again later and it
|
||||
// goes on where it stopped; the last attempt runs without the busy steps.
|
||||
$lastAttempt = $this->attempts() >= $this->tries;
|
||||
$progress = $this->progress();
|
||||
|
||||
if (! $progress['database']) {
|
||||
if (! $this->updateDatabase($server, $commands) && ! $lastAttempt) {
|
||||
$this->retryLater($progress);
|
||||
|
||||
return;
|
||||
}
|
||||
$progress['database'] = true;
|
||||
}
|
||||
|
||||
$listing = $this->runningContainers($server, $commands);
|
||||
if ($listing === null) {
|
||||
self::recordRescanResult($server, false);
|
||||
$this->forgetProgress();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$targets, $runningRefs] = $this->targets($server, $listing['containers']);
|
||||
foreach ($targets as $imageId => $imageTargets) {
|
||||
if (in_array($imageId, $progress['done'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$scans = ScanImage::make()->rescan($server, $imageId, $imageTargets, 0, retryWhenBusy: ! $lastAttempt);
|
||||
if (! $lastAttempt && $scans !== [] && collect($scans)->every(fn (ImageScan $scan): bool => ScanImage::lockWasBusy($scan))) {
|
||||
$this->retryLater($progress);
|
||||
|
||||
return;
|
||||
}
|
||||
$progress['scanned'] += count($scans);
|
||||
$progress['completed'] += count(array_filter($scans, fn (ImageScan $scan): bool => $scan->status === ImageScanStatus::Completed));
|
||||
$this->refreshCandidates($scans);
|
||||
} catch (Throwable $e) {
|
||||
$progress['errors']++;
|
||||
report($e);
|
||||
}
|
||||
$progress['done'][] = $imageId;
|
||||
}
|
||||
self::recordRescanResult($server, $progress['completed'] > 0 || ($progress['scanned'] === 0 && $progress['errors'] === 0));
|
||||
$this->forgetProgress();
|
||||
$this->refreshCountsAfterEndedIgnores($server);
|
||||
|
||||
// A cut list would make running images look stopped.
|
||||
if (! $listing['truncated']) {
|
||||
$this->cleanUp($server, $commands, $targets, $runningRefs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ignore that ended counts its findings again: the stored counts of the resources it covered are written again,
|
||||
* also for images that no longer run. Once per team and day, for ignores that ended in the last two days: by their
|
||||
* end date, or by the 90 days after which an ignore no longer covers a problem with an exploit signal.
|
||||
*/
|
||||
private function refreshCountsAfterEndedIgnores(Server $server): void
|
||||
{
|
||||
try {
|
||||
$signalLimit = now()->subDays(IgnoreMatcher::EXPLOIT_SIGNAL_IGNORE_DAYS);
|
||||
$ended = ImageScanIgnore::query()
|
||||
->where('team_id', $server->team_id)
|
||||
->where(fn ($query) => $query
|
||||
->where(fn ($query) => $query
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->where('expires_at', '>', now()->subDays(2)))
|
||||
->orWhere(fn ($query) => $query
|
||||
->where('created_at', '<=', $signalLimit)
|
||||
->where('created_at', '>', $signalLimit->copy()->subDays(2))
|
||||
->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()->subDays(2)))))
|
||||
->get();
|
||||
if ($ended->isEmpty() || ! Cache::add("image-scan:ended-ignores:{$server->team_id}:".now()->toDateString(), true, now()->addDay())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($ended->contains(fn (ImageScanIgnore $ignore): bool => $ignore->resource_id === null)) {
|
||||
RefreshImageScanSummariesJob::dispatch((int) $server->team_id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ended->map(fn (ImageScanIgnore $ignore) => $ignore->resource)
|
||||
->filter(fn ($resource): bool => $resource instanceof Model)
|
||||
->unique(fn (Model $resource): string => $resource->getMorphClass().':'.$resource->getKey())
|
||||
->each(fn (Model $resource) => ResourceScans::refreshSummaries(ResourceScans::currentFor($resource)));
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What earlier attempts of this job did: database updated, image IDs rescanned, and the counts of the result.
|
||||
*
|
||||
* @return array{database: bool, done: list<string>, scanned: int, completed: int, errors: int}
|
||||
*/
|
||||
private function progress(): array
|
||||
{
|
||||
$empty = ['database' => false, 'done' => [], 'scanned' => 0, 'completed' => 0, 'errors' => 0];
|
||||
$key = $this->progressCacheKey();
|
||||
$stored = $key === null ? null : Cache::get($key);
|
||||
|
||||
return is_array($stored) ? [...$empty, ...$stored] : $empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{database: bool, done: list<string>, scanned: int, completed: int, errors: int} $progress
|
||||
*/
|
||||
private function retryLater(array $progress): void
|
||||
{
|
||||
$key = $this->progressCacheKey();
|
||||
if ($key !== null) {
|
||||
Cache::put($key, $progress, now()->addHours(12));
|
||||
}
|
||||
$this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));
|
||||
}
|
||||
|
||||
private function forgetProgress(): void
|
||||
{
|
||||
$key = $this->progressCacheKey();
|
||||
if ($key !== null) {
|
||||
Cache::forget($key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A released job runs again from its stored payload, which keeps its UUID.
|
||||
*/
|
||||
private function progressCacheKey(): ?string
|
||||
{
|
||||
$uuid = $this->job?->uuid();
|
||||
|
||||
return $uuid === null ? null : "image-rescan:progress:{$uuid}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for newer versions of each rescanned image that has a relevant finding (spec 4.5, 15.4).
|
||||
* The rescan released the lock; each candidate scan takes it again. Runs here, one image after the other, under
|
||||
* the unique lock of RefreshCandidatesJob: a queued or running job of the same scan does the check instead. When
|
||||
* the scan lock is busy, the check moves to a queued job that retries later.
|
||||
*
|
||||
* @param list<ImageScan> $scans
|
||||
*/
|
||||
private function refreshCandidates(array $scans): void
|
||||
{
|
||||
$uniqueLock = new UniqueLock(Cache::store());
|
||||
|
||||
foreach ($scans as $scan) {
|
||||
try {
|
||||
if (! RefreshCandidates::shouldRefresh($scan)) {
|
||||
continue;
|
||||
}
|
||||
$job = new RefreshCandidatesJob($scan);
|
||||
if (! $uniqueLock->acquire($job)) {
|
||||
continue;
|
||||
}
|
||||
$busy = false;
|
||||
try {
|
||||
RefreshCandidates::run($scan, 0, retryWhenBusy: true);
|
||||
} catch (ScanLockBusy) {
|
||||
$busy = true;
|
||||
} finally {
|
||||
$uniqueLock->release($job);
|
||||
}
|
||||
if ($busy) {
|
||||
RefreshCandidatesJob::queueCheck($scan);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention and SBOM deletion under the scan lock, so a scan that starts meanwhile keeps its SBOM and rows. When
|
||||
* the lock is busy they skip a day.
|
||||
*
|
||||
* @param array<string, list<array{0: Model, 1: string}>> $targets
|
||||
* @param array<string, list<string>> $runningRefs
|
||||
*/
|
||||
private function cleanUp(Server $server, ScannerCommands $commands, array $targets, array $runningRefs): void
|
||||
{
|
||||
$lock = ScanImage::serverLock($server);
|
||||
if (! ScanImage::acquireServerLock($lock, $server, 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->applyRetention($server, $targets, $runningRefs);
|
||||
$this->deleteUnusedSboms($server, $commands);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the database under the scan lock, so no scan reads it while it changes. A failed update keeps the
|
||||
* existing database; the scans report when it is too old. False when the lock is busy.
|
||||
*/
|
||||
private function updateDatabase(Server $server, ScannerCommands $commands): bool
|
||||
{
|
||||
$lock = ScanImage::serverLock($server);
|
||||
if (! ScanImage::acquireServerLock($lock, $server, 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = InstanceSettings::get()->image_scan_db_url;
|
||||
|
||||
try {
|
||||
$output = instant_remote_process($commands->dbUpdate(is_string($url) && trim($url) !== '' ? trim($url) : null), $server, throwError: false, timeout: self::TIMEOUT_DB_UPDATE);
|
||||
if ((ScannerCommands::parseOutput($output)['exit_codes']['db-update'] ?? null) === 0) {
|
||||
PrepareImageScannerJob::storeDetails($server, $commands);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The running containers, or null when they could not be listed. `truncated`: the list was cut at the output cap.
|
||||
*
|
||||
* @return array{containers: list<array{id: string, ref: string, labels: Collection<string, mixed>}>, truncated: bool}|null
|
||||
*/
|
||||
private function runningContainers(Server $server, ScannerCommands $commands): ?array
|
||||
{
|
||||
try {
|
||||
$parsed = ScannerCommands::parseOutput(instant_remote_process($commands->runningContainers(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT));
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return null;
|
||||
}
|
||||
if (($parsed['exit_codes']['containers'] ?? null) !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $parsed['payload'];
|
||||
$truncated = strlen($payload) > ScannerCommands::OUTPUT_LIMIT;
|
||||
if ($truncated) {
|
||||
$payload = substr($payload, 0, (int) strrpos(substr($payload, 0, ScannerCommands::OUTPUT_LIMIT), "\n"));
|
||||
}
|
||||
|
||||
$containers = [];
|
||||
foreach (explode("\n", $payload) as $line) {
|
||||
$container = json_decode(trim($line), true);
|
||||
if (! is_array($container)
|
||||
|| ! is_string($container['id'] ?? null)
|
||||
|| preg_match('/\Asha256:[a-f0-9]{64}\z/', $container['id']) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$containers[] = [
|
||||
'id' => $container['id'],
|
||||
'ref' => is_string($container['ref'] ?? null) ? $container['ref'] : '',
|
||||
'labels' => collect(is_array($container['labels'] ?? null) ? $container['labels'] : [])
|
||||
->filter(fn ($value, $key) => is_string($key) && is_scalar($value))
|
||||
->map(fn ($value) => (string) $value),
|
||||
];
|
||||
}
|
||||
|
||||
return ['containers' => $containers, 'truncated' => $truncated];
|
||||
}
|
||||
|
||||
/**
|
||||
* The resources of this server that run each image ID, with the image ref of their container, and the image
|
||||
* refs each resource runs. Containers of resources on other servers, of previews that no longer exist, and of
|
||||
* no resource are skipped.
|
||||
*
|
||||
* @param list<array{id: string, ref: string, labels: Collection<string, mixed>}> $containers
|
||||
* @return array{0: array<string, list<array{0: Model, 1: string}>>, 1: array<string, list<string>>}
|
||||
*/
|
||||
private function targets(Server $server, array $containers): array
|
||||
{
|
||||
$applications = $server->applications();
|
||||
$databases = $server->databases();
|
||||
$services = $server->services()->with(['applications', 'databases'])->get();
|
||||
$targets = [];
|
||||
$runningRefs = [];
|
||||
|
||||
foreach ($containers as $container) {
|
||||
$labels = $container['labels'];
|
||||
if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resource = match (true) {
|
||||
isContainerOfType($labels, 'application') => $this->applicationOwner($applications, $labels),
|
||||
isContainerOfType($labels, 'service') => resolveServiceContainerOwner($services, $labels)[2],
|
||||
isContainerOfType($labels, 'database') => resolveContainerOwner($databases, $labels, 'database'),
|
||||
default => null,
|
||||
};
|
||||
if (! $resource instanceof Model) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $resource->getMorphClass().':'.$resource->getKey();
|
||||
$targets[$container['id']][$key] ??= [$resource, $container['ref'] !== '' ? $container['ref'] : $container['id']];
|
||||
$runningRefs[$key][] = $container['ref'];
|
||||
}
|
||||
|
||||
return [array_map('array_values', $targets), $runningRefs];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Application> $applications
|
||||
* @param Collection<string, mixed> $labels
|
||||
*/
|
||||
private function applicationOwner(Collection $applications, Collection $labels): ?Application
|
||||
{
|
||||
$application = resolveContainerOwner($applications, $labels, 'application');
|
||||
$pullRequestId = (int) $labels->get('coolify.pullRequestId', 0);
|
||||
if (! $application instanceof Application || $pullRequestId === 0) {
|
||||
return $application instanceof Application ? $application : null;
|
||||
}
|
||||
|
||||
$previewExists = ApplicationPreview::query()
|
||||
->where('application_id', $application->id)
|
||||
->where('pull_request_id', $pullRequestId)
|
||||
->exists();
|
||||
|
||||
return $previewExists ? $application : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the scans of running images, of each application's rollback list, and the latest scans per resource;
|
||||
* deletes scans of previews that no longer exist (spec 4.3). Only rows of this server.
|
||||
*
|
||||
* @param array<string, list<array{0: Model, 1: string}>> $targets
|
||||
* @param array<string, list<string>> $runningRefs
|
||||
*/
|
||||
private function applyRetention(Server $server, array $targets, array $runningRefs): void
|
||||
{
|
||||
$running = [];
|
||||
foreach ($targets as $imageId => $imageTargets) {
|
||||
foreach ($imageTargets as [$resource]) {
|
||||
$running[$resource->getMorphClass().':'.$resource->getKey().':'.$imageId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ImageScan::query()
|
||||
->where('server_id', $server->id)
|
||||
->select(['resource_type', 'resource_id'])
|
||||
->distinct()
|
||||
->get()
|
||||
->each(function (ImageScan $group) use ($server, $running, $runningRefs) {
|
||||
$scans = ImageScan::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('resource_type', $group->resource_type)
|
||||
->where('resource_id', $group->resource_id)
|
||||
->with('deploymentQueue:id,pull_request_id')
|
||||
->orderByRaw('coalesce(scanned_at, updated_at) desc')
|
||||
->orderByDesc('id')
|
||||
->get(['id', 'resource_type', 'resource_id', 'image_id', 'image_ref', 'deployment_queue_id', 'scanned_at', 'updated_at']);
|
||||
|
||||
$application = $group->resource_type === (new Application)->getMorphClass()
|
||||
? Application::withTrashed()->with('settings')->find($group->resource_id)
|
||||
: null;
|
||||
$rollback = $application !== null
|
||||
? $this->rollbackList($server, $application, $runningRefs[$group->resource_type.':'.$group->resource_id] ?? [])
|
||||
: ['deployments' => [], 'refs' => []];
|
||||
$previews = $application !== null ? $this->existingPreviews($application) : [];
|
||||
|
||||
$delete = [];
|
||||
foreach ($scans as $index => $scan) {
|
||||
$pullRequestId = $this->previewPullRequestId($scan, $application);
|
||||
$previewGone = $pullRequestId !== null && ! in_array($pullRequestId, $previews, true);
|
||||
$isRunning = isset($running[$scan->resource_type.':'.$scan->resource_id.':'.$scan->image_id]);
|
||||
$keep = $index < self::KEEP_LATEST_SCANS
|
||||
|| in_array((int) $scan->deployment_queue_id, $rollback['deployments'], true)
|
||||
|| in_array($scan->image_ref, $rollback['refs'], true);
|
||||
// A row is per image, so a preview and production can share it: a running image always stays.
|
||||
if (! $isRunning && ($previewGone || ! $keep)) {
|
||||
$delete[] = $scan->id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($delete !== []) {
|
||||
ImageScan::query()->where('server_id', $server->id)->whereKey($delete)->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The deployments and image refs of the images that CleanupDocker keeps for rollbacks besides the running ones:
|
||||
* the latest `docker_images_to_keep` finished deployments whose image is not running, none when the server
|
||||
* turned off image retention.
|
||||
*
|
||||
* @param list<string> $runningRefs
|
||||
* @return array{deployments: list<int>, refs: list<string>}
|
||||
*/
|
||||
private function rollbackList(Server $server, Application $application, array $runningRefs): array
|
||||
{
|
||||
$keep = $server->settings?->disable_application_image_retention ? 0 : max(0, (int) ($application->settings?->docker_images_to_keep ?? 2));
|
||||
if ($keep === 0) {
|
||||
return ['deployments' => [], 'refs' => []];
|
||||
}
|
||||
|
||||
$deployments = ApplicationDeploymentQueue::query()
|
||||
->where('application_id', (string) $application->id)
|
||||
->where('pull_request_id', 0)
|
||||
->where('status', ApplicationDeploymentStatus::FINISHED->value)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->get(['id', 'commit']);
|
||||
|
||||
$repository = $application->docker_registry_image_name ?: $application->uuid;
|
||||
$commits = $deployments->pluck('commit')
|
||||
->filter(fn ($commit) => filled($commit))
|
||||
->unique()
|
||||
->reject(fn (string $commit) => in_array($repository.':'.substr($commit, 0, 128), $runningRefs, true))
|
||||
->take($keep)
|
||||
->values();
|
||||
|
||||
return [
|
||||
'deployments' => $deployments->whereIn('commit', $commits->all())->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
|
||||
'refs' => $commits->map(fn (string $commit) => $repository.':'.substr($commit, 0, 128))->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function existingPreviews(Application $application): array
|
||||
{
|
||||
return ApplicationPreview::query()
|
||||
->where('application_id', $application->id)
|
||||
->pluck('pull_request_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* The pull request of a preview scan: from its deployment, else from the preview tag of its image
|
||||
* (`{image}:pr-{id}-{commit}`). Null for a scan of the application itself.
|
||||
*/
|
||||
private function previewPullRequestId(ImageScan $scan, ?Application $application): ?int
|
||||
{
|
||||
if ($application === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fromDeployment = (int) ($scan->deploymentQueue?->pull_request_id ?? 0);
|
||||
if ($fromDeployment > 0) {
|
||||
return $fromDeployment;
|
||||
}
|
||||
|
||||
$repository = preg_quote($application->docker_registry_image_name ?: $application->uuid, '/');
|
||||
if (preg_match('/\A'.$repository.':pr-(\d+)-/', (string) $scan->image_ref, $match) === 1) {
|
||||
return (int) $match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes SBOMs of this server that no scan uses and that were not used for 30 days, files first.
|
||||
*/
|
||||
private function deleteUnusedSboms(Server $server, ScannerCommands $commands): void
|
||||
{
|
||||
$sboms = ImageSbom::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('last_used_at', '<', now()->subDays(self::SBOM_UNUSED_DAYS))
|
||||
->whereDoesntHave('scans')
|
||||
->orderBy('id')
|
||||
->limit(self::MAX_SBOM_DELETES)
|
||||
->get(['id', 'image_id'])
|
||||
->filter(fn (ImageSbom $sbom) => preg_match('/\Asha256:[a-f0-9]{64}\z/', (string) $sbom->image_id) === 1);
|
||||
if ($sboms->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$output = instant_remote_process($commands->deleteSboms($sboms->pluck('image_id')->values()->all()), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
|
||||
if ((ScannerCommands::parseOutput($output)['exit_codes']['sbom-delete'] ?? null) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImageSbom::query()
|
||||
->where('server_id', $server->id)
|
||||
->whereKey($sboms->pluck('id')->all())
|
||||
->whereDoesntHave('scans')
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ImageScan;
|
||||
use App\Services\ImageScan\ScanNotifications;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
/**
|
||||
* Looks again at the alert of a scan whose exploited problems waited for the version check (spec 15.8). It runs when
|
||||
* the wait ends, so the alert is sent even when the check never stores a result. Unique per scan and scan time, so a
|
||||
* rescan of the same row during a wait gets its own run.
|
||||
*/
|
||||
class ImageScanAlertCheckJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable;
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
public $uniqueFor = 7200;
|
||||
|
||||
/**
|
||||
* @param ?string $scannedAt The scan time of the row when the wait started.
|
||||
*/
|
||||
public function __construct(public int $scanId, public ?string $scannedAt = null)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return $this->scanId.':'.$this->scannedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [60, 300];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$scan = ImageScan::query()->find($this->scanId);
|
||||
if ($scan instanceof ImageScan) {
|
||||
// Errors fail the run, so it is tried again.
|
||||
ScanNotifications::afterScan($scan, candidateCheck: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\SecurityStatusType;
|
||||
use App\Models\ImageScan;
|
||||
use App\Models\ImageScanIgnore;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\ImageScan\VulnerabilityDigest;
|
||||
use App\Services\ImageScan\ResourceSecurity;
|
||||
use App\Services\ImageScan\ScanNotifications;
|
||||
use App\Services\ImageScan\SecuritySentences;
|
||||
use App\Services\ImageScan\SecurityStatus;
|
||||
use App\Services\ImageScan\TeamVulnerabilities;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The daily security digest of one team (spec 15.8): resources that became update_available (new problems with an
|
||||
* action, or a new fixing version) or check_needed, ignores that end within 7 days, and servers whose rescans failed
|
||||
* 2 days in a row. Sends one VulnerabilityDigest only when something changed, then records what it covered in the
|
||||
* notification state of each scan owner (see ScanNotifications). An ignore is named once per end date (cache); a
|
||||
* failing server once per run of failures.
|
||||
*
|
||||
* A resource whose status is check_needed only because its version check did not finish yet (right after a scan) is
|
||||
* skipped until the check finished. While a resource needs a check, its new problems and fixing versions wait for
|
||||
* the next status, at most MAX_HELD_DIGESTS digests; then they are named with "Check needed. {primary action}". Fixes of
|
||||
* problems with an exploit signal never wait: they are named at once the same way.
|
||||
*/
|
||||
class ImageScanDigestJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const IGNORE_WINDOW_DAYS = 7;
|
||||
|
||||
/** While a resource needs a check, its new problems without an exploit signal wait at most this many digests. */
|
||||
public const MAX_HELD_DIGESTS = 3;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public $timeout = 3600;
|
||||
|
||||
public $uniqueFor = 3600;
|
||||
|
||||
public function __construct(public Team $team)
|
||||
{
|
||||
$this->onQueue(isCloud() ? maintenance_queue() : 'default');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->team->id;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! instanceSettings()->is_image_scanning_allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var list<array<string, mixed>> $marks */
|
||||
$marks = [];
|
||||
$resources = [];
|
||||
foreach (TeamVulnerabilities::load($this->team->id)->entries as $entry) {
|
||||
if (! $entry['scanned']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$resource = $this->resourceDigest($entry['resource'], $entry['scans'], $entry['security'], $marks);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($resource !== null) {
|
||||
$resources[] = $resource;
|
||||
}
|
||||
}
|
||||
|
||||
$ignores = $this->endingIgnores();
|
||||
$servers = $this->failingServers();
|
||||
|
||||
if ($resources !== [] || $ignores->isNotEmpty() || $servers->isNotEmpty()) {
|
||||
$order = array_flip(TeamVulnerabilities::STATUS_ORDER);
|
||||
usort($resources, fn (array $a, array $b): int => [$order[$a['status']] ?? 99, $a['name']] <=> [$order[$b['status']] ?? 99, $b['name']]);
|
||||
|
||||
$this->team->notify(new VulnerabilityDigest([
|
||||
'resources' => $resources,
|
||||
'ignores' => $ignores->map(fn (ImageScanIgnore $ignore): array => [
|
||||
'resource' => $ignore->resource instanceof Model ? (string) ScanNotifications::pageResource($ignore->resource)->getAttribute('name') : null,
|
||||
'id' => $ignore->vulnerability_id,
|
||||
'package' => $ignore->package,
|
||||
'expires_at' => $ignore->expires_at->toDateString(),
|
||||
])->values()->all(),
|
||||
'servers' => $servers->map(fn (Server $server): array => [
|
||||
'name' => (string) $server->name,
|
||||
'url' => rtrim(base_url(), '/').'/server/'.$server->uuid,
|
||||
])->values()->all(),
|
||||
]));
|
||||
|
||||
foreach ($ignores as $ignore) {
|
||||
Cache::put(self::ignoreCacheKey($ignore), $ignore->expires_at->toIso8601String(), $ignore->expires_at->copy()->addDay());
|
||||
}
|
||||
foreach ($servers as $server) {
|
||||
ImageRescanJob::markFailureNotified($server);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($marks as $mark) {
|
||||
try {
|
||||
ScanNotifications::applyDigest($mark);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function ignoreCacheKey(ImageScanIgnore $ignore): string
|
||||
{
|
||||
return "image-scan-digest:ignore:{$ignore->id}";
|
||||
}
|
||||
|
||||
/**
|
||||
* The digest line of one resource page (a service covers its applications and databases), or null. Each scan
|
||||
* owner has its own notification state; its current scans are decoded one owner at a time.
|
||||
*
|
||||
* @param Collection<int, ImageScan> $current The current scans, with their owner.
|
||||
* @param list<array<string, mixed>> $marks Gets the marks of the resource only when its digest succeeded.
|
||||
* @return array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list<array<string, mixed>>}|null
|
||||
*/
|
||||
private function resourceDigest(Model $resource, Collection $current, ResourceSecurity $security, array &$marks): ?array
|
||||
{
|
||||
$resourceMarks = [];
|
||||
$digest = $this->buildResourceDigest($resource, $current, $security, $resourceMarks);
|
||||
array_push($marks, ...$resourceMarks);
|
||||
|
||||
return $digest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, ImageScan> $current
|
||||
* @param list<array<string, mixed>> $marks
|
||||
* @return array{name: string, uuid: string, url: ?string, status: string, badge: string, action: ?string, why: list<array<string, mixed>>}|null
|
||||
*/
|
||||
private function buildResourceDigest(Model $resource, Collection $current, ResourceSecurity $security, array &$marks): ?array
|
||||
{
|
||||
$owners = $current->map(fn (ImageScan $scan) => $scan->resource)
|
||||
->filter(fn ($owner): bool => $owner instanceof Model)
|
||||
->unique(fn (Model $owner): string => $owner->getMorphClass().':'.$owner->getKey());
|
||||
|
||||
$plans = $owners->map(fn (Model $owner): ?array => ScanNotifications::digestPlan($owner))->filter()->values();
|
||||
if ($plans->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$needsCheck = $security->status === SecurityStatusType::CheckNeeded;
|
||||
if ($needsCheck && $security->checkReason === SecurityStatus::REASON_VERSIONS_UNCHECKED && $plans->contains('pending', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$active = $plans->where('baseline', false);
|
||||
$holds = fn (array $plan): bool => count($plan['newFixable']) > count($plan['newSignalFixable']) || count($plan['newCandidates']) > count($plan['newSignalCandidates']);
|
||||
// Fixes of problems with an exploit signal are never held; other new problems are held at most MAX_HELD_DIGESTS digests.
|
||||
$bypass = $needsCheck && $active->contains(fn (array $plan): bool => $plan['newSignalFixable'] !== [] || $plan['newSignalCandidates'] !== []);
|
||||
$release = $needsCheck && $active->contains(fn (array $plan): bool => $plan['heldDigests'] >= self::MAX_HELD_DIGESTS) && $active->contains($holds);
|
||||
$report = match ($security->status) {
|
||||
SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable => $active->contains(fn (array $plan): bool => $plan['newFixable'] !== [] || $plan['newCandidates'] !== []),
|
||||
SecurityStatusType::CheckNeeded => $active->isNotEmpty() && ($bypass || $release || ! $active->contains('checkReported', true)),
|
||||
default => false,
|
||||
};
|
||||
|
||||
foreach ($plans as $plan) {
|
||||
$mark = $plan['mark'];
|
||||
$mark['check_reported'] = $needsCheck;
|
||||
$mark['held_digests'] = 0;
|
||||
if ($needsCheck && ! $plan['baseline'] && ! $release) {
|
||||
$mark['add_digest_ids'] = array_values(array_unique(array_map(fn ($finding): string => $finding->id, $plan['newSignalFixable'])));
|
||||
$mark['add_candidates'] = $plan['newSignalCandidates'];
|
||||
$mark['held_digests'] = $holds($plan) ? $plan['heldDigests'] + 1 : 0;
|
||||
}
|
||||
$marks[] = $mark;
|
||||
}
|
||||
|
||||
if (! $report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$action = TeamVulnerabilities::action($security)['text'] ?? null;
|
||||
if ($needsCheck && ($bypass || $release) && filled($security->primary()?->sentence)) {
|
||||
$action = (string) $security->primary()->sentence;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => (string) $resource->getAttribute('name'),
|
||||
'uuid' => (string) $resource->getAttribute('uuid'),
|
||||
'url' => ScanNotifications::link($resource),
|
||||
'status' => $security->status->value,
|
||||
'badge' => (string) SecuritySentences::badge($security->status),
|
||||
'action' => $action,
|
||||
'why' => $needsCheck && ! $bypass && ! $release ? [] : $security->why,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignores of the team that end within IGNORE_WINDOW_DAYS and no digest named with this end date.
|
||||
*
|
||||
* @return Collection<int, ImageScanIgnore>
|
||||
*/
|
||||
private function endingIgnores(): Collection
|
||||
{
|
||||
return ImageScanIgnore::query()
|
||||
->where('team_id', $this->team->id)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '>', now())
|
||||
->where('expires_at', '<=', now()->addDays(self::IGNORE_WINDOW_DAYS))
|
||||
->orderBy('expires_at')
|
||||
->get()
|
||||
->reject(fn (ImageScanIgnore $ignore): bool => Cache::get(self::ignoreCacheKey($ignore)) === $ignore->expires_at->toIso8601String())
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Servers of the team that still scan images and whose daily rescan failed 2 days in a row, not named yet for
|
||||
* this run of failures.
|
||||
*
|
||||
* @return Collection<int, Server>
|
||||
*/
|
||||
private function failingServers(): Collection
|
||||
{
|
||||
return Server::query()
|
||||
->where('team_id', $this->team->id)
|
||||
->with('settings')
|
||||
->get()
|
||||
->filter(fn (Server $server): bool => (bool) $server->settings?->is_image_scan_enabled
|
||||
&& ! $server->isTransferredAway()
|
||||
&& ImageRescanJob::failedDays($server) >= 2
|
||||
&& ! ImageRescanJob::isFailureNotified($server))
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ class PrepareFileDownloadJob implements ShouldQueue
|
||||
public function handle(FileDownloadStorageService $downloads): void
|
||||
{
|
||||
try {
|
||||
$storage = S3Storage::query()->whereTeamId(0)->where('is_usable', true)->find($this->s3StorageId);
|
||||
$storage = S3Storage::query()->s3Only()->whereTeamId(0)->where('is_usable', true)->find($this->s3StorageId);
|
||||
if (! $storage) {
|
||||
throw new \RuntimeException('The file download storage is not available.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\ImageScan\ScanImage;
|
||||
use App\Events\ImageScanUpdated;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Services\ImageScan\ScannerCommands;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Pulls the pinned scanner images on a server and updates the Grype database.
|
||||
*
|
||||
* The progress is kept in the cache (`image-scan-prepare:{server_id}`: pulling, updating_database, ready,
|
||||
* failed) and broadcast as ImageScanUpdated with the scan UUID `prepare`. The database update runs under the scan
|
||||
* lock of the server; when it is busy, the job runs again later instead of waiting for it.
|
||||
*/
|
||||
class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const SCAN_UUID = 'prepare';
|
||||
|
||||
public const STATE_PULLING = 'pulling';
|
||||
|
||||
public const STATE_UPDATING_DATABASE = 'updating_database';
|
||||
|
||||
public const STATE_READY = 'ready';
|
||||
|
||||
public const STATE_FAILED = 'failed';
|
||||
|
||||
public const FAILED_PULLING = 'pulling';
|
||||
|
||||
public const FAILED_SETUP = 'setup';
|
||||
|
||||
public const FAILED_CONNECTION = 'connection';
|
||||
|
||||
public $timeout = 3600;
|
||||
|
||||
public $tries = ScanResourceImagesJob::MAX_ATTEMPTS;
|
||||
|
||||
/** An unexpected error fails the job; only a busy scan lock retries it. */
|
||||
public $maxExceptions = 1;
|
||||
|
||||
public $uniqueFor = 3600;
|
||||
|
||||
private const TIMEOUT_PREPARE = 1900;
|
||||
|
||||
private const TIMEOUT_DB_UPDATE = 400;
|
||||
|
||||
private const TIMEOUT_DB_STATUS = 120;
|
||||
|
||||
public function __construct(public Server $server)
|
||||
{
|
||||
// Same queue as the other scan jobs: self-hosted Horizon works through 'high' first.
|
||||
$this->onQueue(isCloud() ? maintenance_queue() : 'default');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->server->id;
|
||||
}
|
||||
|
||||
public static function stateCacheKey(Server $server): string
|
||||
{
|
||||
return "image-scan-prepare:{$server->id}";
|
||||
}
|
||||
|
||||
public static function builtAtCacheKey(Server $server): string
|
||||
{
|
||||
return self::stateCacheKey($server).':built-at';
|
||||
}
|
||||
|
||||
public static function diskUsedCacheKey(Server $server): string
|
||||
{
|
||||
return self::stateCacheKey($server).':disk-used';
|
||||
}
|
||||
|
||||
public static function failedStepCacheKey(Server $server): string
|
||||
{
|
||||
return self::stateCacheKey($server).':failed-step';
|
||||
}
|
||||
|
||||
public static function forget(Server $server): void
|
||||
{
|
||||
Cache::forget(self::stateCacheKey($server));
|
||||
Cache::forget(self::failedStepCacheKey($server));
|
||||
Cache::forget(self::builtAtCacheKey($server));
|
||||
Cache::forget(self::diskUsedCacheKey($server));
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$server = $this->server->fresh(['settings']);
|
||||
if (! $server instanceof Server || $server->isTransferredAway()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = InstanceSettings::get();
|
||||
if (! $settings->is_image_scanning_allowed || ! $server->settings?->is_image_scan_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->prepare($server, $settings->image_scan_db_url);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$this->setState($server, self::STATE_FAILED, self::FAILED_CONNECTION);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$this->setState($this->server, self::STATE_FAILED);
|
||||
}
|
||||
|
||||
private function prepare(Server $server, ?string $dbUrl): void
|
||||
{
|
||||
$commands = new ScannerCommands($server, Str::lower(Str::random(24)));
|
||||
|
||||
$this->setState($server, self::STATE_PULLING);
|
||||
$pulled = self::run($commands->prepare(), $server, self::TIMEOUT_PREPARE);
|
||||
if ($pulled['exit_codes'] === []) {
|
||||
$this->setState($server, self::STATE_FAILED, self::FAILED_CONNECTION);
|
||||
|
||||
return;
|
||||
}
|
||||
foreach (['pull-syft', 'pull-grype', 'pull-regctl', 'pull-init'] as $step) {
|
||||
if (($pulled['exit_codes'][$step] ?? null) !== 0) {
|
||||
$this->setState($server, self::STATE_FAILED, self::FAILED_PULLING);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (($pulled['exit_codes']['db-volume'] ?? null) !== 0) {
|
||||
$this->setState($server, self::STATE_FAILED, self::FAILED_SETUP);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->isStillEnabled($server)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setState($server, self::STATE_UPDATING_DATABASE);
|
||||
$lock = ScanImage::serverLock($server);
|
||||
if (! ScanImage::acquireServerLock($lock, $server, 0)) {
|
||||
if ($this->attempts() >= $this->tries) {
|
||||
$this->setState($server, self::STATE_FAILED);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$updated = self::run($commands->dbUpdate(is_string($dbUrl) && trim($dbUrl) !== '' ? trim($dbUrl) : null), $server, self::TIMEOUT_DB_UPDATE);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
if (($updated['exit_codes']['db-update'] ?? null) !== 0) {
|
||||
$this->setState($server, self::STATE_FAILED);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->isStillEnabled($server)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::storeDetails($server, $commands);
|
||||
$this->setState($server, self::STATE_READY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $commands
|
||||
* @return array{exit_codes: array<string, int>, sizes: array<string, int>, payload: string}
|
||||
*/
|
||||
private static function run(array $commands, Server $server, int $timeout): array
|
||||
{
|
||||
return ScannerCommands::parseOutput(instant_remote_process($commands, $server, throwError: false, timeout: $timeout));
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches the database build date and the disk use of the scanner data that the server page shows.
|
||||
*/
|
||||
public static function storeDetails(Server $server, ScannerCommands $commands): void
|
||||
{
|
||||
Cache::forget(self::builtAtCacheKey($server));
|
||||
Cache::forget(self::diskUsedCacheKey($server));
|
||||
|
||||
try {
|
||||
$status = self::run($commands->dbStatus(), $server, self::TIMEOUT_DB_STATUS);
|
||||
$report = ($status['exit_codes']['db-status'] ?? null) === 0 ? json_decode($status['payload'], true) : null;
|
||||
$built = is_array($report) && ($report['valid'] ?? false) === true ? ($report['built'] ?? null) : null;
|
||||
if (is_string($built)) {
|
||||
Cache::put(self::builtAtCacheKey($server), CarbonImmutable::parse($built)->toIso8601ZuluString(), now()->addDays(7));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$usage = self::run($commands->diskUsage(), $server, self::TIMEOUT_DB_STATUS);
|
||||
if (isset($usage['sizes']['disk-db']) || isset($usage['sizes']['disk-sboms'])) {
|
||||
Cache::put(self::diskUsedCacheKey($server), ($usage['sizes']['disk-db'] ?? 0) + ($usage['sizes']['disk-sboms'] ?? 0), now()->addDays(7));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function isStillEnabled(Server $server): bool
|
||||
{
|
||||
$enabled = (bool) $server->settings()->value('is_image_scan_enabled');
|
||||
if (! $enabled) {
|
||||
self::forget($server);
|
||||
}
|
||||
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $failedStep one of the FAILED_* steps; without it a failure is put on the running step
|
||||
*/
|
||||
private function setState(Server $server, string $state, ?string $failedStep = null): void
|
||||
{
|
||||
$running = in_array($state, [self::STATE_PULLING, self::STATE_UPDATING_DATABASE], true);
|
||||
$previous = Cache::get(self::stateCacheKey($server));
|
||||
$failedStep ??= in_array($previous, [self::STATE_PULLING, self::STATE_UPDATING_DATABASE], true) ? $previous : null;
|
||||
if ($state === self::STATE_FAILED && $failedStep !== null) {
|
||||
Cache::put(self::failedStepCacheKey($server), $failedStep, now()->addDays(7));
|
||||
} elseif ($running) {
|
||||
Cache::forget(self::failedStepCacheKey($server));
|
||||
}
|
||||
Cache::put(self::stateCacheKey($server), $state, $running ? now()->addHour() : now()->addDays(7));
|
||||
ImageScanUpdated::dispatch($server->team_id, $server->uuid, self::SCAN_UUID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\ImageScan\RefreshCandidates;
|
||||
use App\Events\ImageScanUpdated;
|
||||
use App\Models\ImageScan;
|
||||
use App\Services\ImageScan\ScanLockBusy;
|
||||
use App\Services\ImageScan\ScanNotifications;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Looks for newer versions of a scanned image after the scan, so deploys never wait for it. The job never sleeps on
|
||||
* the scan lock of the server: when it is busy (or a deploy gate waits for it), the job runs again later.
|
||||
*/
|
||||
class RefreshCandidatesJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = ScanResourceImagesJob::MAX_ATTEMPTS;
|
||||
|
||||
/** An unexpected error fails the job; only a busy lock retries it. */
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/** Two candidate scans and the scan of the current base image, plus the registry calls. */
|
||||
public $timeout = 7200;
|
||||
|
||||
public $uniqueFor = 7200;
|
||||
|
||||
public $deleteWhenMissingModels = true;
|
||||
|
||||
public function __construct(public ImageScan $scan)
|
||||
{
|
||||
// Self-hosted Horizon works through 'high' first, so scans there would delay deployments.
|
||||
$this->onQueue(isCloud() ? maintenance_queue() : 'default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues the check and marks it as queued (`RefreshCandidates::isQueued()`).
|
||||
*/
|
||||
public static function queueCheck(ImageScan $scan): void
|
||||
{
|
||||
RefreshCandidates::markQueued($scan, (new self($scan))->uniqueFor);
|
||||
self::dispatch($scan);
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->scan->id;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$scan = $this->scan->fresh();
|
||||
// The deploy gate or the daily rescan checked it meanwhile.
|
||||
if (! $scan instanceof ImageScan || RefreshCandidates::isFresh($scan)) {
|
||||
RefreshCandidates::forgetQueued($this->scan);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
RefreshCandidates::run($scan, 0, retryWhenBusy: $this->attempts() < $this->tries);
|
||||
RefreshCandidates::forgetQueued($scan);
|
||||
} catch (ScanLockBusy) {
|
||||
$this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The check stored no result, so an alert that waits for it is sent now (spec 15.8).
|
||||
*/
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
RefreshCandidates::forgetQueued($this->scan);
|
||||
$scan = $this->scan->fresh();
|
||||
if (! $scan instanceof ImageScan) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScanNotifications::reevaluate($scan, checkGaveUp: true);
|
||||
$resource = $scan->resource;
|
||||
if ($resource instanceof Model) {
|
||||
$teamId = method_exists($resource, 'team') ? $resource->team()?->id : null;
|
||||
ImageScanUpdated::dispatch($teamId, (string) $resource->getAttribute('uuid'), (string) $scan->uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Writes the summaries of the current scans of every resource of a team again, with their stored security status, after
|
||||
* a team-wide ignore was added or removed, a team-wide ignore ended, or the upgrade to security statuses, so the status
|
||||
* in sidebars, markers, and lists matches the findings.
|
||||
*/
|
||||
class RefreshImageScanSummariesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public function __construct(public int $teamId)
|
||||
{
|
||||
$this->onQueue(isCloud() ? maintenance_queue() : 'default');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
ResourceScans::refreshSummaries(ResourceScans::currentForTeam($this->teamId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Server\AuthorizedKeys;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Removes the old key from a server some time after a rotation moved the server to the new key.
|
||||
* Deployments, backups, and scheduled tasks that started before the rotation still log in with the old key.
|
||||
* Safe to run again: removing a key that is not on the server changes nothing.
|
||||
*/
|
||||
class RemoveRotatedAuthorizedKeyJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/** The shortest wait before the old key is removed. A server waits at least its deployment timeout. */
|
||||
public const GRACE_SECONDS = 3600;
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
public $backoff = [300, 1800];
|
||||
|
||||
public $timeout = 300;
|
||||
|
||||
/**
|
||||
* @param string $oldPublicKeyBody The public key, so the job works after the old key record is deleted.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $serverId,
|
||||
public int $oldPrivateKeyId,
|
||||
public string $oldPrivateKeyUuid,
|
||||
public string $oldPublicKeyBody,
|
||||
) {}
|
||||
|
||||
public static function delaySeconds(Server $server): int
|
||||
{
|
||||
return max(self::GRACE_SECONDS, (int) data_get($server, 'settings.dynamic_timeout') + 300);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
return;
|
||||
}
|
||||
if ($this->serverUsesOldKey($server)) {
|
||||
$this->report($server, 'moved_old_not_removed', 'The old key stays on the server, because the server uses it again.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! AuthorizedKeys::canRunOn($server)) {
|
||||
$this->report($server, 'moved_old_not_removed', 'The old key stays on the server, because Coolify does not run commands on it.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! AuthorizedKeys::removePublicKey($server, $this->oldPublicKeyBody, $this->oldPrivateKeyId)) {
|
||||
$this->report($server, 'moved_old_not_removed', 'The old key stays on the server, because another server for this host uses it.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->report($server, 'moved', 'Moved to the new key. The old key was removed.');
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$server = Server::find($this->serverId);
|
||||
if ($server) {
|
||||
$message = $exception ? RotatePrivateKeyJob::errorWithoutHost($server, $exception) : 'The job stopped.';
|
||||
$this->report($server, 'moved_old_not_removed', 'The old key was not removed: '.$message);
|
||||
}
|
||||
}
|
||||
|
||||
private function serverUsesOldKey(Server $server): bool
|
||||
{
|
||||
if ($server->private_key_id === $this->oldPrivateKeyId) {
|
||||
return true;
|
||||
}
|
||||
$currentKey = $server->privateKey;
|
||||
$oldFingerprint = AuthorizedKeys::fingerprint($this->oldPublicKeyBody);
|
||||
if (! $currentKey || ! $oldFingerprint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($currentKey->fingerprint ?: PrivateKey::generateFingerprint($currentKey->private_key)) === $oldFingerprint;
|
||||
}
|
||||
|
||||
/** Updates the row of the server in the result of the rotation, while the rotation still waits for this job. */
|
||||
private function report(Server $server, string $status, string $message): void
|
||||
{
|
||||
auditLog('system.server.ssh_key_old_removed', [
|
||||
'team_id' => $server->team_id,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'old_private_key_uuid' => $this->oldPrivateKeyUuid,
|
||||
'result' => $status,
|
||||
], $status === 'moved' ? 'info' : 'warning');
|
||||
|
||||
$cacheKey = RotatePrivateKeyJob::cacheKey($this->oldPrivateKeyUuid);
|
||||
$state = Cache::get($cacheKey);
|
||||
if (data_get($state, "servers.{$server->uuid}.status") !== RotatePrivateKeyJob::OLD_KEY_PENDING) {
|
||||
return;
|
||||
}
|
||||
$state['servers'][$server->uuid] = [...$state['servers'][$server->uuid], 'status' => $status, 'message' => $message];
|
||||
Cache::put($cacheKey, $state, RotatePrivateKeyJob::RESULT_TTL_SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\ImageScan\ScanImage;
|
||||
use App\Enums\ImageScanErrorCode;
|
||||
use App\Enums\ImageScanStatus;
|
||||
use App\Events\ImageScanUpdated;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ImageScan;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Services\ImageScan\ImageScanPolicyResolver;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Scans the images of a resource one at a time, after it started, so the scan is not in the critical path of a
|
||||
* deploy (spec 4.4).
|
||||
*/
|
||||
class ScanResourceImagesJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/** Waits before the next attempt when the scan lock of the server is busy or a deploy gate waits for it. */
|
||||
public const LOCK_RETRY_DELAYS = [60, 120, 300];
|
||||
|
||||
/** Attempts of a job, for a busy lock and for an image that is still being pulled. */
|
||||
public const MAX_ATTEMPTS = 10;
|
||||
|
||||
/** Images scanned when the job derives them from the resource. */
|
||||
public const MAX_DERIVED_IMAGES = 5;
|
||||
|
||||
/** A service or database scan waits this long after the start was queued, so the image is pulled first. */
|
||||
public const START_DELAY_SECONDS = 30;
|
||||
|
||||
/** Attempts of a job after a start: later ones wait for an image that is still being pulled. */
|
||||
public const START_MAX_ATTEMPTS = 3;
|
||||
|
||||
public const START_RETRY_DELAY_SECONDS = 120;
|
||||
|
||||
/** Progress stage of a scan that "Scan now" queued and that has not started yet. */
|
||||
public const QUEUED_STAGE = 'queued';
|
||||
|
||||
public $tries = self::MAX_ATTEMPTS;
|
||||
|
||||
/** An unexpected error fails the job; only a busy lock or a missing image retry it. */
|
||||
public $maxExceptions = 1;
|
||||
|
||||
public $timeout;
|
||||
|
||||
/** A resource deleted before the delayed scan runs needs no scan. */
|
||||
public $deleteWhenMissingModels = true;
|
||||
|
||||
/**
|
||||
* @param list<string> $imageRefs Empty: the images of the resource (imageRefsFor()).
|
||||
* @param bool $retryWhenImageMissing A job after a service or database start retries an image that is not pulled yet.
|
||||
* @param ?string $baseImageRef The base image of the Dockerfile build of the application.
|
||||
* @param bool $pullWhenMissing "Scan now": pull an image that is not on the server.
|
||||
*/
|
||||
public function __construct(public Model $resource, public ?int $deploymentQueueId = null, public array $imageRefs = [], public bool $retryWhenImageMissing = false, public ?string $baseImageRef = null, public bool $pullWhenMissing = false)
|
||||
{
|
||||
// Self-hosted Horizon works through 'high' first, so scans there would delay deployments.
|
||||
$this->onQueue(isCloud() ? maintenance_queue() : 'default');
|
||||
$images = $imageRefs === [] ? $this->derivedImageCount() : count($imageRefs);
|
||||
$this->timeout = $images * ScanImage::LOCK_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues the scan of a service or standalone database that was started. Never blocks or fails the start.
|
||||
*/
|
||||
public static function dispatchAfterStart(Model $resource): void
|
||||
{
|
||||
try {
|
||||
if (! ImageScanPolicyResolver::isScanOn($resource)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::dispatch($resource, null, [], true)->delay(now()->addSeconds(self::START_DELAY_SECONDS));
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a scan that "Scan now" queued as running at once: marks each scanned owner as queued and hides its last
|
||||
* error. The scan replaces the mark with its progress; the job removes marks that are left when it ends.
|
||||
*
|
||||
* @param list<string> $imageRefs
|
||||
*/
|
||||
public static function markQueued(Model $resource, array $imageRefs = []): void
|
||||
{
|
||||
foreach (self::markedOwners($resource, $imageRefs) as $owner) {
|
||||
Cache::put(ScanImage::progressCacheKey($owner), [
|
||||
'run_id' => self::QUEUED_STAGE,
|
||||
'stage' => self::QUEUED_STAGE,
|
||||
'started_at' => now()->toIso8601String(),
|
||||
], ScanImage::LOCK_TTL);
|
||||
Cache::forget(ScanImage::lastErrorCacheKey($owner));
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$deployment = $this->deployment();
|
||||
$server = $this->server($deployment);
|
||||
if (! $server instanceof Server) {
|
||||
$this->clearQueuedMarks();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$done = $this->doneTargets();
|
||||
$missing = [];
|
||||
$lastAttempt = $this->attempts() >= $this->tries;
|
||||
foreach ($this->scanTargets() as [$resource, $imageRef]) {
|
||||
$key = $this->targetKey($resource, $imageRef);
|
||||
if (in_array($key, $done, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseImageRef = $resource === $this->resource && $resource instanceof Application ? $this->baseImageRef : null;
|
||||
// Never sleeps on the server lock: a deploy gate may need it. A busy lock retries the job later.
|
||||
$scan = ScanImage::run($server, $resource, $imageRef, $deployment, 0, $baseImageRef, retryWhenBusy: ! $lastAttempt, pullWhenMissing: $this->pullWhenMissing);
|
||||
|
||||
if (! $lastAttempt && ScanImage::lockWasBusy($scan)) {
|
||||
$this->rememberDoneTargets($done);
|
||||
$this->release(self::lockRetryDelay($this->attempts()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldRetry($scan)) {
|
||||
// Only the last attempt leaves the error visible.
|
||||
Cache::forget(ScanImage::lastErrorCacheKey($resource));
|
||||
$missing[] = $key;
|
||||
} else {
|
||||
$done[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing !== []) {
|
||||
$this->rememberDoneTargets($done);
|
||||
$this->release(self::START_RETRY_DELAY_SECONDS);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->clearQueuedMarks();
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception = null): void
|
||||
{
|
||||
$this->clearQueuedMarks();
|
||||
}
|
||||
|
||||
/**
|
||||
* The owners that markQueued() marks: the application, or the scanned applications and databases of a service.
|
||||
*
|
||||
* @param list<string> $imageRefs
|
||||
* @return list<Model>
|
||||
*/
|
||||
private static function markedOwners(Model $resource, array $imageRefs): array
|
||||
{
|
||||
if ($resource instanceof Application) {
|
||||
return $imageRefs === [] ? [] : [$resource];
|
||||
}
|
||||
|
||||
return array_map(fn (array $target): Model => $target[0], ResourceScans::scanTargets($resource));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the queued marks that no scan replaced (for example a scan that could not start), and tells open pages.
|
||||
*/
|
||||
private function clearQueuedMarks(): void
|
||||
{
|
||||
foreach (self::markedOwners($this->resource, $this->imageRefs) as $owner) {
|
||||
$key = ScanImage::progressCacheKey($owner);
|
||||
if (data_get(Cache::get($key), 'stage') !== self::QUEUED_STAGE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Cache::forget($key);
|
||||
$teamId = method_exists($owner, 'team') ? $owner->team()?->id : null;
|
||||
ImageScanUpdated::dispatch($teamId, (string) $owner->getAttribute('uuid'), self::QUEUED_STAGE, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The wait before attempt $attempt + 1 after a busy lock: 60, 120, then 300 seconds.
|
||||
*/
|
||||
public static function lockRetryDelay(int $attempt): int
|
||||
{
|
||||
return self::LOCK_RETRY_DELAYS[min(max($attempt, 1), count(self::LOCK_RETRY_DELAYS)) - 1];
|
||||
}
|
||||
|
||||
private function shouldRetry(ImageScan $scan): bool
|
||||
{
|
||||
return $this->retryWhenImageMissing
|
||||
&& $this->attempts() < min(self::START_MAX_ATTEMPTS, $this->tries)
|
||||
&& $scan->status === ImageScanStatus::Failed
|
||||
&& $scan->error_code === ImageScanErrorCode::ImageMissing;
|
||||
}
|
||||
|
||||
private function targetKey(Model $resource, string $imageRef): string
|
||||
{
|
||||
return $resource->getMorphClass().':'.$resource->getKey().':'.$imageRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* A released job runs again from its stored payload, so the targets of earlier attempts are kept in the cache.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function doneTargets(): array
|
||||
{
|
||||
$uuid = $this->job?->uuid();
|
||||
|
||||
return $uuid === null ? [] : (array) Cache::get("image-scan:start-done:{$uuid}", []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $done
|
||||
*/
|
||||
private function rememberDoneTargets(array $done): void
|
||||
{
|
||||
$uuid = $this->job?->uuid();
|
||||
if ($uuid !== null) {
|
||||
Cache::put("image-scan:start-done:{$uuid}", $done, now()->addHours(2));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The images a resource runs. Applications: the images of their latest completed scans. Services: the images of
|
||||
* their applications and databases. Standalone databases: their image.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function imageRefsFor(Model $resource): array
|
||||
{
|
||||
if ($resource instanceof Application) {
|
||||
return ImageScan::query()
|
||||
->where('resource_type', $resource->getMorphClass())
|
||||
->where('resource_id', $resource->getKey())
|
||||
->where('status', ImageScanStatus::Completed)
|
||||
->orderByDesc('scanned_at')
|
||||
->orderByDesc('id')
|
||||
->pluck('image_ref')
|
||||
->unique()
|
||||
->take(self::MAX_DERIVED_IMAGES)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return array_map(fn (array $target): string => $target[1], $this->scanTargetsFor($resource));
|
||||
}
|
||||
|
||||
/**
|
||||
* Each image with the resource its result belongs to. A service keeps results per application and database.
|
||||
*
|
||||
* @return list<array{0: Model, 1: string}>
|
||||
*/
|
||||
private function scanTargets(): array
|
||||
{
|
||||
if ($this->imageRefs !== []) {
|
||||
$targets = array_map(fn ($imageRef): array => [$this->resource, $imageRef], $this->imageRefs);
|
||||
} elseif ($this->resource instanceof Application) {
|
||||
$targets = array_map(fn (string $imageRef): array => [$this->resource, $imageRef], $this->imageRefsFor($this->resource));
|
||||
} else {
|
||||
$targets = $this->scanTargetsFor($this->resource);
|
||||
}
|
||||
|
||||
$seen = [];
|
||||
|
||||
return array_values(array_filter($targets, function (array $target) use (&$seen): bool {
|
||||
[$resource, $imageRef] = $target;
|
||||
if (! is_string($imageRef)) {
|
||||
return false;
|
||||
}
|
||||
$key = $this->targetKey($resource, $imageRef);
|
||||
if (isset($seen[$key])) {
|
||||
return false;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{0: Model, 1: string}>
|
||||
*/
|
||||
private function scanTargetsFor(Model $resource): array
|
||||
{
|
||||
return ResourceScans::scanTargets($resource);
|
||||
}
|
||||
|
||||
private function derivedImageCount(): int
|
||||
{
|
||||
if ($this->resource instanceof Service) {
|
||||
return max(self::MAX_DERIVED_IMAGES, $this->resource->applications()->count() + $this->resource->databases()->count());
|
||||
}
|
||||
|
||||
return self::MAX_DERIVED_IMAGES;
|
||||
}
|
||||
|
||||
private function deployment(): ?ApplicationDeploymentQueue
|
||||
{
|
||||
if ($this->deploymentQueueId === null || ! $this->resource instanceof Application) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ApplicationDeploymentQueue::query()
|
||||
->whereKey($this->deploymentQueueId)
|
||||
->where('application_id', (string) $this->resource->getKey())
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The server of the deployment (an additional server runs its own copy of the image), else the server the
|
||||
* scan policy of the resource uses.
|
||||
*/
|
||||
private function server(?ApplicationDeploymentQueue $deployment): ?Server
|
||||
{
|
||||
$server = $deployment?->server ?? ImageScanPolicyResolver::mainServerOf($this->resource);
|
||||
|
||||
return $server instanceof Server ? $server : null;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,11 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
/** Owner marker that the SSH probe read from the host; empty when the host has none. */
|
||||
private ?string $ownerMarker = null;
|
||||
|
||||
/** The server sent a host key that does not match the pinned key. */
|
||||
private bool $hostKeyChanged = false;
|
||||
|
||||
private string $sshErrorOutput = '';
|
||||
|
||||
public function __construct(
|
||||
public Server $server
|
||||
) {}
|
||||
@@ -84,9 +89,30 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce mode cannot connect without the key, so the check reads the key also of an unreachable server.
|
||||
if (SshMultiplexingHelper::hostKeyCheckingMode($this->server) === 'enforce') {
|
||||
SshMultiplexingHelper::learnHostKey($this->server, evenIfUnreachable: true);
|
||||
}
|
||||
|
||||
// Check basic connectivity first
|
||||
$isReachable = $this->checkConnection();
|
||||
|
||||
if ($this->hostKeyChanged) {
|
||||
// A failed host key check with the pinned key (an unreadable known_hosts file) is a normal connection error.
|
||||
$this->hostKeyChanged = $this->server->recordHostKeyMismatch($this->sshErrorOutput);
|
||||
}
|
||||
|
||||
if (! $isReachable && $this->hostKeyChanged && SshMultiplexingHelper::hostKeyCheckingMode($this->server) === 'enforce') {
|
||||
// In enforce mode a changed host key blocks every connection: stop at once, without the retry threshold.
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
$this->logConnectionStateChange($wasReachable, $wasUsable, false, false, 'ssh_host_key_changed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $isReachable) {
|
||||
$this->server->increment('unreachable_count');
|
||||
|
||||
@@ -116,6 +142,9 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn mode connects without a pinned key. Commands do not scan an unreachable server, so learn the key now.
|
||||
SshMultiplexingHelper::learnHostKey($this->server, evenIfUnreachable: true);
|
||||
|
||||
// Server is reachable, check if Docker is available
|
||||
$isUsable = $this->checkDockerAvailability();
|
||||
|
||||
@@ -217,6 +246,9 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if ($this->attemptConnection()) {
|
||||
return true;
|
||||
}
|
||||
if ($this->hostKeyChanged) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -226,6 +258,8 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
try {
|
||||
$process = $this->runSsh(self::probeCommand(), self::SSH_TIMEOUT_SECONDS, disableMultiplexing: true);
|
||||
$this->sshErrorOutput = $process->errorOutput();
|
||||
$this->hostKeyChanged = SshMultiplexingHelper::isHostKeyMismatch($this->sshErrorOutput);
|
||||
if ($process->exitCode() !== 0) {
|
||||
$this->connectionError = 'ssh exit '.$process->exitCode().': '.Str::limit(trim($process->errorOutput()), 300);
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -88,9 +89,40 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
// Process server-specific scheduled tasks
|
||||
$this->processScheduledTasks($servers);
|
||||
|
||||
$this->dispatchImageScanDigests($servers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One image vulnerability digest per team and day, for teams with a server that scans images (spec 8).
|
||||
*/
|
||||
private function dispatchImageScanDigests(Collection $servers): void
|
||||
{
|
||||
if (! $this->settings->is_image_scanning_allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$servers->filter(fn (Server $server): bool => (bool) $server->settings?->is_image_scan_enabled && ! $server->isTransferredAway())
|
||||
->pluck('team_id')
|
||||
->unique()
|
||||
->each(function ($teamId): void {
|
||||
try {
|
||||
if (shouldRunCronNow(self::imageScanDigestCron((int) $teamId), $this->instanceTimezone, "image-scan-digest:{$teamId}", $this->executionTime)) {
|
||||
$team = Team::find($teamId);
|
||||
if ($team instanceof Team) {
|
||||
ImageScanDigestJob::dispatch($team);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Failed to dispatch ImageScanDigestJob', [
|
||||
'team_id' => $teamId,
|
||||
'error' => get_class($e).': '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects only the IDs, so the paid-subscription filter runs once and the full server rows are loaded
|
||||
* in batches by primary key. Loading every server in one query kept all of them in worker memory.
|
||||
@@ -228,6 +260,15 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
ServerPatchCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Daily image rescan, staggered per server. The cron is read only for servers that can be scanned, so a
|
||||
// server that comes back catches up a missed day.
|
||||
if ($server->settings?->is_image_scan_enabled
|
||||
&& ImageRescanJob::canRunOn($server)
|
||||
&& shouldRunCronNow(self::imageRescanCron($server), $serverTimezone, "image-rescan:{$server->id}", $this->executionTime)
|
||||
) {
|
||||
ImageRescanJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
|
||||
}
|
||||
|
||||
@@ -258,6 +299,25 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return sprintf('%d %d * * 0', $slot % 60, 4 + intdiv($slot, 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily image rescan at a stable per-server time between 02:00 and 04:59 (spec 4.5).
|
||||
*/
|
||||
public static function imageRescanCron(Server $server): string
|
||||
{
|
||||
return sprintf('%d %d * * *', $server->id % 60, 2 + intdiv($server->id % 180, 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily image vulnerability digest at a stable per-team time between 06:00 and 07:59, after the rescans of
|
||||
* 02:00 to 04:59 (spec 8).
|
||||
*/
|
||||
public static function imageScanDigestCron(int $teamId): string
|
||||
{
|
||||
$slot = $teamId % 120;
|
||||
|
||||
return sprintf('%d %d * * *', $slot % 60, 6 + intdiv($slot, 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the backoff cycle interval based on how many consecutive times a server has been unreachable.
|
||||
* Higher counts → less frequent checks (based on 5-min cloud cycle):
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\GithubApp;
|
||||
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\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Mirrors the status of a Coolify deployment to a GitHub Deployment, so GitHub shows the
|
||||
* branch or pull request as deployed. Each run sends the current status, so a late or
|
||||
* repeated run converges to the same result.
|
||||
*/
|
||||
class SyncGithubDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable;
|
||||
|
||||
/**
|
||||
* API errors are tried three times. Waits for the per-deployment lock are not counted, so a
|
||||
* terminal sync that waits behind a slow sync still runs (see retryUntil()).
|
||||
*/
|
||||
public $maxExceptions = 3;
|
||||
|
||||
public $backoff = 10;
|
||||
|
||||
/**
|
||||
* Seconds that one request waits for GitHub. A slow GitHub must not hold a queue worker.
|
||||
*/
|
||||
public const HTTP_TIMEOUT_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* Longer than the worst case of all requests with their retries, shorter than the lock
|
||||
* expiry, so the per-deployment lock is held while the job runs.
|
||||
*/
|
||||
public $timeout = 100;
|
||||
|
||||
private const LOCK_EXPIRES_AFTER = 120;
|
||||
|
||||
private const LOCK_RELEASE_DELAY = 5;
|
||||
|
||||
public function __construct(public int $deploymentQueueId, public bool $deactivate = false)
|
||||
{
|
||||
// Not the deployment queue: a slow GitHub must not delay deployments.
|
||||
$this->onQueue('default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a sync when the deployment belongs to a GitHub App that can write deployments.
|
||||
*/
|
||||
public static function dispatchFor(ApplicationDeploymentQueue $deployment): void
|
||||
{
|
||||
if (filled($deployment->parent_deployment_uuid)) {
|
||||
// Deployments to additional servers are part of the main deployment.
|
||||
return;
|
||||
}
|
||||
|
||||
// A soft-deleted application still needs its last status on GitHub.
|
||||
$source = Application::withTrashed()->find($deployment->application_id)?->source;
|
||||
if (! $source instanceof GithubApp || ! $source->canSyncDeployments()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::dispatch($deployment->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the GitHub Deployments of a closed pull request as inactive. This also covers
|
||||
* deployments that GitHub does not know yet, so a late sync cannot create one.
|
||||
*/
|
||||
public static function deactivatePullRequest(Application $application, int $pullRequestId): void
|
||||
{
|
||||
ApplicationDeploymentQueue::query()
|
||||
->where('application_id', $application->id)
|
||||
->where('pull_request_id', $pullRequestId)
|
||||
->where(fn ($query) => $query->whereNull('github_deployment_state')->orWhere('github_deployment_state', '!=', 'inactive'))
|
||||
->pluck('id')
|
||||
->each(fn (int $deploymentQueueId) => self::dispatch($deploymentQueueId, deactivate: true));
|
||||
}
|
||||
|
||||
/**
|
||||
* All syncs of one deployment share this lock, and handle() reads the state while it
|
||||
* holds the lock. Thus a deactivation cannot happen between that read and the requests.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new WithoutOverlapping("github-deployment:{$this->deploymentQueueId}"))->releaseAfter(self::LOCK_RELEASE_DELAY)->expireAfter(self::LOCK_EXPIRES_AFTER)];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sync can wait for a lock that is held up to its expiry. Fixed at dispatch.
|
||||
*/
|
||||
public function retryUntil(): \DateTimeInterface
|
||||
{
|
||||
return now()->addSeconds(self::LOCK_EXPIRES_AFTER + 60);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::find($this->deploymentQueueId);
|
||||
$application = $deployment ? Application::withTrashed()->find($deployment->application_id) : null;
|
||||
$source = $application?->source;
|
||||
if (! $source instanceof GithubApp || ! $source->canSyncDeployments()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inactive is the end state: a closed pull request must not show as deployed again.
|
||||
if ($deployment->github_deployment_state === 'inactive') {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $this->deactivate ? 'inactive' : $this->githubState($deployment->status);
|
||||
if ($state === null || $state === $deployment->github_deployment_state) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repository = gitRepositorySlug($application->git_repository);
|
||||
$githubDeploymentId = $deployment->github_deployment_id;
|
||||
if (! $githubDeploymentId) {
|
||||
if ($state === 'inactive') {
|
||||
// Keep the marker, so a late sync does not create a GitHub Deployment.
|
||||
ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_state' => 'inactive']);
|
||||
|
||||
return;
|
||||
}
|
||||
// The deployment ended before GitHub knew about it. The deployment job sets
|
||||
// horizon_job_worker together with IN_PROGRESS, so a failure without it never started.
|
||||
if ($state === 'error' || ($state === 'failure' && blank($deployment->horizon_job_worker))) {
|
||||
return;
|
||||
}
|
||||
$githubDeploymentId = $this->createGithubDeployment($deployment, $application, $source, $repository);
|
||||
ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_id' => $githubDeploymentId]);
|
||||
}
|
||||
|
||||
githubApi($source, "repos/{$repository}/deployments/{$githubDeploymentId}/statuses", 'post', array_filter([
|
||||
'state' => $state,
|
||||
'description' => $this->description($state),
|
||||
'log_url' => $this->logUrl($deployment, $application),
|
||||
'environment_url' => $state === 'success' ? $this->environmentUrl($deployment, $application) : null,
|
||||
]), timeout: self::HTTP_TIMEOUT_SECONDS);
|
||||
|
||||
ApplicationDeploymentQueue::whereKey($deployment->id)->update(['github_deployment_state' => $state]);
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
Log::warning('Failed to sync the GitHub deployment.', [
|
||||
'deployment_queue_id' => $this->deploymentQueueId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function githubState(string $status): ?string
|
||||
{
|
||||
return match ($status) {
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value => 'in_progress',
|
||||
ApplicationDeploymentStatus::FINISHED->value => 'success',
|
||||
ApplicationDeploymentStatus::FAILED->value => 'failure',
|
||||
ApplicationDeploymentStatus::CANCELLED_BY_USER->value => 'error',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function createGithubDeployment(ApplicationDeploymentQueue $deployment, Application $application, GithubApp $source, string $repository): int
|
||||
{
|
||||
$isPreview = $deployment->pull_request_id !== 0;
|
||||
$environment = $isPreview ? "{$application->name} (preview)" : $application->name;
|
||||
|
||||
$response = githubApi($source, "repos/{$repository}/deployments", 'post', [
|
||||
'ref' => $this->ref($deployment, $application, $source, $repository),
|
||||
'environment' => Str::limit($environment, 255, ''),
|
||||
'description' => Str::limit($isPreview ? "Coolify preview for PR #{$deployment->pull_request_id}" : 'Coolify deployment', 140, ''),
|
||||
// GitHub would merge the default branch into the ref, or wait for all commit checks.
|
||||
'auto_merge' => false,
|
||||
'required_contexts' => [],
|
||||
'transient_environment' => $isPreview,
|
||||
'production_environment' => ! $isPreview,
|
||||
], timeout: self::HTTP_TIMEOUT_SECONDS);
|
||||
|
||||
return (int) $response['data']->get('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub links a deployment to a pull request only when its ref is the head branch. A fork
|
||||
* branch is not in the base repository, so a fork preview uses the head commit. GitHub
|
||||
* resolves a branch to its current commit, so the branch is used only while it still
|
||||
* points to the deployed commit.
|
||||
*/
|
||||
private function ref(ApplicationDeploymentQueue $deployment, Application $application, GithubApp $source, string $repository): string
|
||||
{
|
||||
$commit = filled($deployment->commit) && $deployment->commit !== 'HEAD' ? $deployment->commit : null;
|
||||
|
||||
if ($deployment->pull_request_id !== 0) {
|
||||
$pullRequest = githubApi($source, "repos/{$repository}/pulls/{$deployment->pull_request_id}", timeout: self::HTTP_TIMEOUT_SECONDS)['data'];
|
||||
$headRepository = (string) data_get($pullRequest, 'head.repo.full_name', '');
|
||||
$baseRepository = (string) data_get($pullRequest, 'base.repo.full_name', $repository);
|
||||
$headBranch = data_get($pullRequest, 'head.ref');
|
||||
$headCommit = (string) data_get($pullRequest, 'head.sha');
|
||||
$isDeployedCommit = $commit === null || strcasecmp($commit, $headCommit) === 0;
|
||||
if (filled($headBranch) && strcasecmp($headRepository, $baseRepository) === 0 && $isDeployedCommit) {
|
||||
return $headBranch;
|
||||
}
|
||||
|
||||
return $commit ?? $headCommit;
|
||||
}
|
||||
|
||||
// The branch can move past the deployed commit before the GitHub deployment is created.
|
||||
return $commit ?? $application->git_branch;
|
||||
}
|
||||
|
||||
private function description(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'in_progress' => 'Deploying with Coolify.',
|
||||
'success' => 'Deployed with Coolify.',
|
||||
'failure' => 'The Coolify deployment failed.',
|
||||
'error' => 'The Coolify deployment was cancelled.',
|
||||
default => 'The pull request was closed.',
|
||||
};
|
||||
}
|
||||
|
||||
private function logUrl(ApplicationDeploymentQueue $deployment, Application $application): string
|
||||
{
|
||||
return base_url()."/project/{$application->environment->project->uuid}/environment/{$application->environment->uuid}/application/{$application->uuid}/deployment/{$deployment->deployment_uuid}";
|
||||
}
|
||||
|
||||
private function environmentUrl(ApplicationDeploymentQueue $deployment, Application $application): ?string
|
||||
{
|
||||
if ($deployment->pull_request_id !== 0) {
|
||||
$preview = $application->previews()->where('pull_request_id', $deployment->pull_request_id)->first();
|
||||
$domains = $application->build_pack === 'dockercompose'
|
||||
? collect(json_decode((string) $preview?->docker_compose_domains, true) ?: [])->pluck('domain')->filter()->first()
|
||||
: $preview?->fqdn;
|
||||
} else {
|
||||
$domains = $application->build_pack === 'dockercompose'
|
||||
? collect(json_decode((string) $application->docker_compose_domains, true) ?: [])->pluck('domain')->filter()->first()
|
||||
: $application->fqdn;
|
||||
}
|
||||
|
||||
$url = trim((string) str($domains)->explode(',')->first());
|
||||
|
||||
return str_starts_with($url, 'http://') || str_starts_with($url, 'https://') ? $url : null;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Server\UpdateCoolify;
|
||||
use App\Exceptions\UpgradeAlreadyRunningException;
|
||||
use App\Models\Server;
|
||||
use App\Services\AutoUpdateNotifier;
|
||||
use App\Services\CoolifyUpgradeStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -45,9 +48,17 @@ class UpdateCoolifyJob implements ShouldBeEncrypted, ShouldQueue
|
||||
UpdateCoolify::run(false); // false means it's not a manual update
|
||||
|
||||
Log::info('Coolify upgrade script started. Result: /data/coolify/source/.upgrade-status, log: /data/coolify/source/upgrade-*.log.');
|
||||
} catch (UpgradeAlreadyRunningException $e) {
|
||||
// The running upgrade keeps its saved attempt, so its result is still reported.
|
||||
Log::warning('Automatic update skipped: '.$e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('UpdateCoolifyJob failed: '.$e->getMessage());
|
||||
// Consider implementing a notification to administrators
|
||||
try {
|
||||
CoolifyUpgradeStatus::writeError(Server::findOrFail(0), $e->getMessage());
|
||||
} catch (\Throwable $statusException) {
|
||||
report($statusException);
|
||||
}
|
||||
AutoUpdateNotifier::failed($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Models\VolumeBackupS3Replica;
|
||||
use App\Notifications\ScheduledJobRecovered;
|
||||
use App\Notifications\VolumeBackup\BackupFailed;
|
||||
use App\Notifications\VolumeBackup\BackupSuccess;
|
||||
use App\Services\RcloneTransfer;
|
||||
use App\Services\ScheduledJobDeliveryService;
|
||||
use App\Services\Security\SensitiveDataRedactor;
|
||||
use App\Support\BackupCompression;
|
||||
@@ -90,9 +91,10 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz';
|
||||
$backupLocation = $backupDirectory.'/'.$filename;
|
||||
$this->execution->update(['filename' => $backupLocation]);
|
||||
// Streaming writes no local archive, so it can upload to one destination only. A curl upload needs the size
|
||||
// of the file before it starts, so it cannot stream.
|
||||
$streamToS3 = $this->backup->disable_local_backup && $destinations->count() === 1 && ! $destinations->first()->usesCurlTransfer();
|
||||
// Streaming writes no local archive, so it can upload to one destination only. Only S3 storages stream, and a
|
||||
// curl upload needs the size of the file before it starts, so it cannot stream.
|
||||
$streamToS3 = $this->backup->disable_local_backup && $destinations->count() === 1
|
||||
&& $destinations->first()->isS3() && ! $destinations->first()->usesCurlTransfer();
|
||||
|
||||
try {
|
||||
$source = $this->backup->sourcePath();
|
||||
@@ -174,7 +176,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execution->refreshS3Summary();
|
||||
$this->execution->update(['s3_cleanup_pending' => false]);
|
||||
} elseif ($this->backup->save_s3 && $destinations->isEmpty()) {
|
||||
$warning = 'S3 upload failed: The selected S3 storage no longer exists.';
|
||||
$warning = 'Storage upload failed: The selected backup storage no longer exists.';
|
||||
} elseif ($this->backup->save_s3) {
|
||||
$s3CleanupPending = true;
|
||||
$this->execution->update(['s3_cleanup_pending' => true]);
|
||||
@@ -184,14 +186,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if ($s3Uploaded) {
|
||||
$s3CleanupPending = false;
|
||||
} else {
|
||||
$warning = 'S3 upload failed: '.implode(' ', $uploadErrors);
|
||||
$warning = 'Storage upload failed: '.implode(' ', $uploadErrors);
|
||||
|
||||
try {
|
||||
VolumeBackupRecoveryJob::cleanupS3Upload($this->execution);
|
||||
$s3CleanupPending = false;
|
||||
} catch (Throwable $cleanupException) {
|
||||
VolumeBackupRecoveryJob::dispatch($this->execution);
|
||||
$warning .= ' Partial S3 upload cleanup failed: '.$cleanupException->getMessage();
|
||||
$warning .= ' Partial storage upload cleanup failed: '.$cleanupException->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +202,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
deleteBackupsLocally($backupLocation, $server, throwError: true);
|
||||
$localStorageDeleted = true;
|
||||
} catch (Throwable $exception) {
|
||||
$warning = 'S3 upload succeeded, but the local archive could not be deleted: '.$exception->getMessage();
|
||||
$warning = 'Storage upload succeeded, but the local archive could not be deleted: '.$exception->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +449,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
/** @var VolumeBackupS3Replica $replica */
|
||||
try {
|
||||
if (! $replica->s3) {
|
||||
throw new \RuntimeException('The selected S3 storage no longer exists.');
|
||||
throw new \RuntimeException('The selected backup storage no longer exists.');
|
||||
}
|
||||
|
||||
$this->uploadToS3($replica->s3, $backupLocation, $size, $backupDirectory, $server);
|
||||
@@ -468,6 +470,17 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
$s3->testConnection(shouldSave: true);
|
||||
$containerName = 'volume-upload-'.$this->execution->uuid;
|
||||
if (! $s3->isS3()) {
|
||||
$transfer = new RcloneTransfer($s3, $server, $containerName);
|
||||
try {
|
||||
$transfer->start([$backupLocation.':'.$backupLocation.':ro'], timeout: $this->timeout);
|
||||
$transfer->upload($backupLocation, $backupLocation, $this->timeout);
|
||||
} finally {
|
||||
instant_remote_process([$transfer->removeCommand()], $server, throwError: false, disableMultiplexing: true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$image = coolifyHelperImage().':'.getHelperVersion();
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -220,7 +221,7 @@ class Index extends Component
|
||||
{
|
||||
return [
|
||||
'remoteServerName' => 'required|string',
|
||||
'remoteServerHost' => 'required|string',
|
||||
'remoteServerHost' => ['required', 'string', new ValidServerIp],
|
||||
'remoteServerPort' => 'required|integer|min:1|max:65535',
|
||||
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
|
||||
];
|
||||
@@ -290,6 +291,7 @@ class Index extends Component
|
||||
]);
|
||||
|
||||
$this->createdPrivateKey = $privateKey;
|
||||
$this->privateKey = null;
|
||||
$this->currentState = 'create-server';
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('privateKey', 'Failed to save private key: '.$e->getMessage());
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Support\SmtpPresets;
|
||||
use Exception;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Named SMTP servers and SMTP templates on an email settings page.
|
||||
*
|
||||
* The component keeps the SMTP fields of the server in use (smtpHost, smtpPort, ...).
|
||||
* Switching and deleting save at once. A new server only empties the fields, and a
|
||||
* template fills them in the browser (smtp-server-picker partial); the normal Save stores them.
|
||||
*
|
||||
* @property InstanceSettings|EmailNotificationSettings $settings
|
||||
*/
|
||||
trait ManagesSmtpServers
|
||||
{
|
||||
public ?string $smtpServerName = null;
|
||||
|
||||
public string $selectedSmtpServer = 'active';
|
||||
|
||||
public bool $isNewSmtpServer = false;
|
||||
|
||||
public ?string $newSmtpServerName = null;
|
||||
|
||||
public ?string $renamedSmtpServerName = null;
|
||||
|
||||
public ?string $newSmtpServerTemplate = null;
|
||||
|
||||
/**
|
||||
* Save the settings model.
|
||||
*/
|
||||
abstract protected function saveEmailSettings(): void;
|
||||
|
||||
/**
|
||||
* Load the component fields from the settings model.
|
||||
*/
|
||||
abstract protected function loadEmailSettings(): void;
|
||||
|
||||
/**
|
||||
* @return list<array{value: string, label: string}>
|
||||
*/
|
||||
public function smtpServerOptions(): array
|
||||
{
|
||||
$options = [['value' => 'active', 'label' => $this->settings->smtpServerName()]];
|
||||
foreach ($this->settings->savedSmtpServers() as $server) {
|
||||
$options[] = ['value' => $server['id'], 'label' => $server['name']];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new server, with the fields of the selected template or empty fields. The server in use stays saved until the new one is saved.
|
||||
*/
|
||||
public function addSmtpServer(): void
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->validate([
|
||||
'newSmtpServerName' => 'required|string|max:255',
|
||||
'newSmtpServerTemplate' => ['nullable', Rule::in(array_keys(SmtpPresets::PRESETS))],
|
||||
], [
|
||||
'newSmtpServerName.required' => 'Server name is required.',
|
||||
]);
|
||||
$preset = SmtpPresets::PRESETS[$this->newSmtpServerTemplate] ?? null;
|
||||
|
||||
$this->isNewSmtpServer = true;
|
||||
$this->smtpServerName = $this->newSmtpServerName;
|
||||
$this->newSmtpServerName = null;
|
||||
$this->newSmtpServerTemplate = null;
|
||||
$this->smtpHost = $preset['host'] ?? null;
|
||||
$this->smtpPort = isset($preset['port']) ? (string) $preset['port'] : null;
|
||||
$this->smtpEncryption = $preset['encryption'] ?? 'starttls';
|
||||
$this->smtpUsername = $preset['username'] ?? null;
|
||||
$this->smtpPassword = null;
|
||||
$this->smtpTimeout = null;
|
||||
$this->smtpEhloDomain = null;
|
||||
}
|
||||
|
||||
public function cancelNewSmtpServer(): void
|
||||
{
|
||||
$this->isNewSmtpServer = false;
|
||||
$this->resetErrorBag();
|
||||
$this->loadEmailSettings();
|
||||
}
|
||||
|
||||
public function switchSmtpServer(string $id)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
if ($id === 'active') {
|
||||
return null;
|
||||
}
|
||||
if (! $this->settings->switchToSmtpServer($id)) {
|
||||
throw new Exception('This SMTP server does not exist anymore. Reload the page.');
|
||||
}
|
||||
|
||||
$this->saveEmailSettings();
|
||||
$this->isNewSmtpServer = false;
|
||||
$this->resetErrorBag();
|
||||
$this->loadEmailSettings();
|
||||
$this->dispatch('success', 'Switched to SMTP server '.$this->settings->smtpServerName().'.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->selectedSmtpServer = 'active';
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the server in use. Only the name is saved; other unsaved changes on the page stay unsaved.
|
||||
*/
|
||||
public function renameSmtpServer()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->validate(['renamedSmtpServerName' => 'required|string|max:255'], [
|
||||
'renamedSmtpServerName.required' => 'Server name is required.',
|
||||
]);
|
||||
|
||||
$this->smtpServerName = $this->settings->smtp_server_name = $this->renamedSmtpServerName;
|
||||
$this->renamedSmtpServerName = null;
|
||||
$this->saveEmailSettings();
|
||||
$this->dispatch('success', 'SMTP server renamed.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the server in use and use the next saved server.
|
||||
*/
|
||||
public function deleteSmtpServer()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
if ($this->isNewSmtpServer) {
|
||||
$this->cancelNewSmtpServer();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $this->settings->smtpServerName();
|
||||
if (! $this->settings->deleteActiveSmtpServer()) {
|
||||
throw new Exception('This is the only SMTP server. To stop using SMTP, select another provider.');
|
||||
}
|
||||
|
||||
$this->saveEmailSettings();
|
||||
$this->resetErrorBag();
|
||||
$this->loadEmailSettings();
|
||||
$this->dispatch('success', 'SMTP server '.$name.' deleted. Now using '.$this->settings->smtpServerName().'.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the SMTP fields, including the sender, to the settings model. A new server first keeps the
|
||||
* server in use in the saved list, so set no smtp_* column before this call.
|
||||
*/
|
||||
protected function fillSmtpServerSettings(): void
|
||||
{
|
||||
if ($this->isNewSmtpServer) {
|
||||
$this->settings->keepActiveSmtpServer();
|
||||
$this->isNewSmtpServer = false;
|
||||
}
|
||||
|
||||
$this->settings->smtp_server_name = $this->smtpServerName;
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||
$this->settings->smtp_host = $this->smtpHost;
|
||||
$this->settings->smtp_port = $this->smtpPort;
|
||||
$this->settings->smtp_encryption = $this->smtpEncryption;
|
||||
$this->settings->smtp_username = $this->smtpUsername;
|
||||
$this->settings->smtp_password = $this->smtpPassword;
|
||||
$this->settings->smtp_timeout = $this->smtpTimeout;
|
||||
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Services\Access\AccessResolver;
|
||||
use App\Services\ImageScan\TeamVulnerabilities;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
@@ -41,6 +42,14 @@ class Dashboard extends Component
|
||||
#[Locked]
|
||||
public bool $showsTeamSections = true;
|
||||
|
||||
/**
|
||||
* UUIDs of the resources the user can see that need a security update now (update_now).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
#[Locked]
|
||||
public array $securityUpdateNow = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->showsTeamSections = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id);
|
||||
@@ -51,6 +60,7 @@ class Dashboard extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->securityUpdateNow = TeamVulnerabilities::updateNowResources(currentTeam()->id);
|
||||
$this->privateKeys = $this->showsTeamSections ? PrivateKey::ownedByCurrentTeamCached() : collect();
|
||||
$this->servers = $this->showsTeamSections ? Server::ownedByCurrentTeamCached() : collect();
|
||||
$this->projects = Project::ownedByCurrentTeam()
|
||||
@@ -174,6 +184,16 @@ class Dashboard extends Component
|
||||
return in_array($column, $columns, true) && in_array($direction, ['asc', 'desc'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* "1 resource needs a security update now." / "3 resources need a security update now."
|
||||
*/
|
||||
public static function securityCalloutTitle(int $count): string
|
||||
{
|
||||
return $count === 1
|
||||
? '1 resource needs a security update now.'
|
||||
: "{$count} resources need a security update now.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Unexpired invitations for the user's email to teams the user has not joined yet.
|
||||
*
|
||||
|
||||
@@ -25,6 +25,7 @@ class ActiveDeployments extends Component
|
||||
'server_name',
|
||||
'server_id',
|
||||
'status',
|
||||
'scan_gate_result',
|
||||
'created_at',
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
@@ -819,6 +819,20 @@ class GlobalSearch extends Component
|
||||
'link' => route('team.audit-log'),
|
||||
'search_text' => 'team audit log activity history events',
|
||||
] : null,
|
||||
instanceSettings()->is_image_scanning_allowed ? [
|
||||
'name' => 'Security',
|
||||
'type' => 'navigation',
|
||||
'description' => 'What each resource of your team needs, from its last image scan',
|
||||
'link' => route('vulnerabilities'),
|
||||
'search_text' => 'vulnerabilities cve ghsa image scanning fixable packages security',
|
||||
] : null,
|
||||
instanceSettings()->is_image_scanning_allowed ? [
|
||||
'name' => 'Team Image Scanning',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Scan rules and ignored vulnerabilities of the team',
|
||||
'link' => route('team.image-scanning'),
|
||||
'search_text' => 'team image scanning vulnerabilities cve scan rules alert block deploys ignored security',
|
||||
] : null,
|
||||
isInstanceAdmin() ? [
|
||||
'name' => 'Team Admin View',
|
||||
'type' => 'navigation',
|
||||
@@ -929,6 +943,13 @@ class GlobalSearch extends Component
|
||||
'link' => route('settings.updates'),
|
||||
'search_text' => 'settings updates auto update upgrade version frequency',
|
||||
],
|
||||
[
|
||||
'name' => 'Image Scanning Settings',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Allow image scanning, the vulnerability database, and default scan rules',
|
||||
'link' => route('settings.image-scanning'),
|
||||
'search_text' => 'settings image scanning vulnerabilities cve scan rules grype database mirror alert block deploys security',
|
||||
],
|
||||
[
|
||||
'name' => 'Instance Backup',
|
||||
'type' => 'navigation',
|
||||
@@ -962,6 +983,7 @@ class GlobalSearch extends Component
|
||||
route('profile'),
|
||||
route('profile.appearance'),
|
||||
route('security.api-tokens'),
|
||||
route('vulnerabilities'),
|
||||
];
|
||||
$navigation = $navigation->whereIn('link', $projectPages)->values();
|
||||
}
|
||||
@@ -1177,8 +1199,8 @@ class GlobalSearch extends Component
|
||||
// Storage - can be created if user is admin or owner
|
||||
if ($user->isAdmin() || $user->isOwner()) {
|
||||
$items->push([
|
||||
'name' => 'S3 Storage',
|
||||
'description' => 'Add S3 storage for backups and file uploads',
|
||||
'name' => 'Storage',
|
||||
'description' => 'Add S3, SFTP, FTPS, or WebDAV storage for backups',
|
||||
'quickcommand' => '(type: new storage)',
|
||||
'type' => 'storage',
|
||||
'category' => 'Quick Actions',
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\ImageScan;
|
||||
|
||||
use App\Enums\SecurityStatusType;
|
||||
use App\Services\ImageScan\TeamVulnerabilities;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/**
|
||||
* The team Security page (spec 15.8): the resources of the session team the user can see with their stored security
|
||||
* status, most urgent first, and a search by vulnerability ID or package across their current scans. No buttons and
|
||||
* no bulk actions: the Action column links to the Security card of each resource. Resources that were not scanned are
|
||||
* hidden until the user shows them (the line under the table counts them) or filters by "Not checked".
|
||||
*/
|
||||
class FleetVulnerabilities extends Component
|
||||
{
|
||||
use ListensToTeamChannel;
|
||||
use WithPagination;
|
||||
|
||||
#[Url(except: '')]
|
||||
public string $search = '';
|
||||
|
||||
/**
|
||||
* Untyped, like $projects and $statuses: a malformed URL or request value (a string, nested arrays) must not fail
|
||||
* the page. `normalizeFilters()` keeps only known string values.
|
||||
*
|
||||
* @var list<string> Server UUIDs.
|
||||
*/
|
||||
public $servers = [];
|
||||
|
||||
/** @var list<string> Project UUIDs. */
|
||||
public $projects = [];
|
||||
|
||||
/** @var list<string> SecurityStatusType values. */
|
||||
#[Url(except: [])]
|
||||
public $statuses = [];
|
||||
|
||||
/** Shows the resources that were not scanned when no status filter is set. */
|
||||
#[Url(except: false)]
|
||||
public bool $showNotChecked = false;
|
||||
|
||||
public int $perPage = 25;
|
||||
|
||||
/** At most one reload per this many seconds for scan events of other resources. */
|
||||
public const RELOAD_INTERVAL = 10;
|
||||
|
||||
/** When a scan event last reloaded the page (Unix time). */
|
||||
public int $reloadedAt = 0;
|
||||
|
||||
/** A scan result came in during the interval; the page reloads once the interval ends. */
|
||||
public bool $reloadPending = false;
|
||||
|
||||
private ?TeamVulnerabilities $fleet = null;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
return $this->teamChannelListeners([
|
||||
'ImageScanUpdated' => 'scanUpdated',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A scan finished somewhere in the team: render again with the new results, at most once per RELOAD_INTERVAL
|
||||
* seconds, because a scan of a whole server sends one event per image. Progress updates of a running scan change no
|
||||
* result, so they do not reload the page.
|
||||
*
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
public function scanUpdated(array $event = []): void
|
||||
{
|
||||
if (data_get($event, 'final', true) === false) {
|
||||
$this->skipRender();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now()->getTimestamp();
|
||||
if ($now - $this->reloadedAt < self::RELOAD_INTERVAL) {
|
||||
$this->reloadPending = true;
|
||||
$this->skipRender();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reloadedAt = $now;
|
||||
$this->reloadPending = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the scan results that came in during the last interval.
|
||||
*/
|
||||
public function reloadPendingResults(): void
|
||||
{
|
||||
if (! $this->reloadPending) {
|
||||
$this->skipRender();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reloadedAt = now()->getTimestamp();
|
||||
$this->reloadPending = false;
|
||||
}
|
||||
|
||||
public function toggleNotChecked(): void
|
||||
{
|
||||
$this->showNotChecked = ! $this->showNotChecked;
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updated(string $property): void
|
||||
{
|
||||
$this->normalizeFilters();
|
||||
|
||||
if (in_array($property, ['search', 'perPage'], true)) {
|
||||
$this->resetPage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only string values, and only allowed statuses. Servers and projects are checked against the options in
|
||||
* render().
|
||||
*/
|
||||
private function normalizeFilters(): void
|
||||
{
|
||||
$strings = fn (mixed $values): array => array_values(array_unique(array_filter(is_array($values) ? $values : [], 'is_string')));
|
||||
|
||||
$this->servers = $strings($this->servers);
|
||||
$this->projects = $strings($this->projects);
|
||||
$this->statuses = array_values(array_intersect($strings($this->statuses), TeamVulnerabilities::STATUS_ORDER));
|
||||
}
|
||||
|
||||
public function toggleServer(string $uuid): void
|
||||
{
|
||||
$this->servers = $this->toggle($this->servers, $uuid);
|
||||
}
|
||||
|
||||
public function toggleProject(string $uuid): void
|
||||
{
|
||||
$this->projects = $this->toggle($this->projects, $uuid);
|
||||
}
|
||||
|
||||
public function toggleStatus(string $status): void
|
||||
{
|
||||
if (in_array($status, TeamVulnerabilities::STATUS_ORDER, true)) {
|
||||
$this->statuses = $this->toggle($this->statuses, $status);
|
||||
}
|
||||
}
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->servers = [];
|
||||
$this->projects = [];
|
||||
$this->statuses = [];
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function toggle(array $values, string $value): array
|
||||
{
|
||||
$this->resetPage();
|
||||
|
||||
return in_array($value, $values, true)
|
||||
? array_values(array_diff($values, [$value]))
|
||||
: [...$values, $value];
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$this->normalizeFilters();
|
||||
$user = auth()->user();
|
||||
$team = currentTeam();
|
||||
$allowed = (bool) instanceSettings()->is_image_scanning_allowed;
|
||||
|
||||
if (! $allowed) {
|
||||
return view('livewire.image-scan.fleet-vulnerabilities', ['allowed' => false]);
|
||||
}
|
||||
|
||||
$fleet = $this->fleet($team->id);
|
||||
$entries = $fleet->entries;
|
||||
|
||||
$serverOptions = $entries->pluck('server')->filter()->unique('id')
|
||||
->mapWithKeys(fn ($server) => [$server->uuid => $server->name])->sort(SORT_NATURAL | SORT_FLAG_CASE)->all();
|
||||
$projectOptions = $entries->pluck('project')->filter()->unique('id')
|
||||
->mapWithKeys(fn ($project) => [$project->uuid => $project->name])->sort(SORT_NATURAL | SORT_FLAG_CASE)->all();
|
||||
$this->servers = array_values(array_intersect($this->servers, array_keys($serverOptions)));
|
||||
$this->projects = array_values(array_intersect($this->projects, array_keys($projectOptions)));
|
||||
|
||||
$inScope = $entries->filter(fn (array $entry): bool => ($this->servers === [] || in_array($entry['server']?->uuid, $this->servers, true))
|
||||
&& ($this->projects === [] || in_array($entry['project']?->uuid, $this->projects, true)))->values();
|
||||
$statusCounts = $inScope->countBy(fn (array $entry): string => $entry['security']->status->value);
|
||||
$statusOptions = collect(TeamVulnerabilities::STATUS_ORDER)
|
||||
->mapWithKeys(fn (string $status): array => [$status => TeamVulnerabilities::statusLabel(SecurityStatusType::from($status)).' ('.number_format($statusCounts[$status] ?? 0).')'])
|
||||
->all();
|
||||
$filtered = $inScope->filter(fn (array $entry): bool => $this->statuses === []
|
||||
? $this->showNotChecked || $entry['security']->status !== SecurityStatusType::NotChecked
|
||||
: in_array($entry['security']->status->value, $this->statuses, true))->values();
|
||||
|
||||
$notScanned = $inScope->filter(fn (array $entry): bool => $entry['security']->status === SecurityStatusType::NotChecked);
|
||||
$searching = trim($this->search) !== '';
|
||||
|
||||
$rows = $searching
|
||||
? $fleet->search($filtered->filter(fn (array $entry): bool => $entry['scanned'])->values(), $this->search)
|
||||
: self::byStatus($filtered);
|
||||
$notScannedServers = $notScanned->pluck('server')->filter()->unique('id')->count();
|
||||
|
||||
return view('livewire.image-scan.fleet-vulnerabilities', [
|
||||
'allowed' => true,
|
||||
'items' => $this->paginate($rows),
|
||||
'searching' => $searching,
|
||||
'notScannedLine' => $notScanned->isEmpty() ? null : self::notScannedSentence($notScanned->count(), $notScannedServers),
|
||||
// The toggle only matters while no status filter is set.
|
||||
'notCheckedToggle' => $notScanned->isEmpty() || $this->statuses !== [] || $searching ? null : ($this->showNotChecked ? 'Hide them' : 'Show them'),
|
||||
'hidesNotChecked' => $this->statuses === [] && ! $this->showNotChecked && $notScanned->isNotEmpty(),
|
||||
'searchCapped' => $searching && $fleet->searchWasCapped(),
|
||||
'serversNotScanning' => $user->isAdminOfTeam($team->id) ? TeamVulnerabilities::serversNotScanning($team->id) : 0,
|
||||
'serverOptions' => $serverOptions,
|
||||
'projectOptions' => $projectOptions,
|
||||
'statusOptions' => $statusOptions,
|
||||
'hasFilters' => $this->servers !== [] || $this->projects !== [] || $this->statuses !== [],
|
||||
'hasResources' => $entries->isNotEmpty(),
|
||||
'stateLabels' => TeamVulnerabilities::STATE_LABELS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* "3 resources on 2 servers were not scanned, so Coolify cannot tell." Resources without a server leave out the
|
||||
* servers part when no server is left.
|
||||
*/
|
||||
public static function notScannedSentence(int $resources, int $servers): string
|
||||
{
|
||||
$subject = $resources.' '.str('resource')->plural($resources);
|
||||
if ($servers > 0) {
|
||||
$subject .= ' on '.$servers.' '.str('server')->plural($servers);
|
||||
}
|
||||
|
||||
return $subject.($resources === 1 ? ' was' : ' were').' not scanned, so Coolify cannot tell.';
|
||||
}
|
||||
|
||||
/**
|
||||
* The team's resources and their stored statuses, loaded once per request.
|
||||
*/
|
||||
private function fleet(int $teamId): TeamVulnerabilities
|
||||
{
|
||||
if ($this->fleet?->teamId !== $teamId) {
|
||||
$this->fleet = TeamVulnerabilities::load($teamId);
|
||||
}
|
||||
|
||||
return $this->fleet;
|
||||
}
|
||||
|
||||
/**
|
||||
* update_now, update_available, no_fix, check_needed, ok, not_checked; then the resource name.
|
||||
*
|
||||
* @param Collection<int, array<string, mixed>> $entries
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
public static function byStatus(Collection $entries): Collection
|
||||
{
|
||||
$rank = array_flip(TeamVulnerabilities::STATUS_ORDER);
|
||||
|
||||
return $entries
|
||||
->sort(fn (array $a, array $b): int => ($rank[$a['security']->status->value] <=> $rank[$b['security']->status->value])
|
||||
?: strnatcasecmp((string) $a['resource']->name, (string) $b['resource']->name))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function paginate(Collection $rows): LengthAwarePaginator
|
||||
{
|
||||
$this->perPage = max(1, min(100, $this->perPage));
|
||||
$lastPage = max(1, (int) ceil($rows->count() / $this->perPage));
|
||||
$page = max(1, min((int) $this->getPage(), $lastPage));
|
||||
if ($page !== (int) $this->getPage()) {
|
||||
$this->setPage($page);
|
||||
}
|
||||
|
||||
return new LengthAwarePaginator($rows->forPage($page, $this->perPage)->values(), $rows->count(), $this->perPage, $page);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\ImageScan;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Instance settings page for image scanning: the instance switch, the vulnerability database URL, and the default rules.
|
||||
*/
|
||||
class InstanceImageScanning extends Component
|
||||
{
|
||||
public ?string $isImageScanningAllowed = '1';
|
||||
|
||||
public ?string $imageScanDbUrl = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (! isInstanceAdmin()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$settings = InstanceSettings::get();
|
||||
$this->isImageScanningAllowed = $settings->is_image_scanning_allowed ? '1' : '0';
|
||||
$this->imageScanDbUrl = $settings->image_scan_db_url;
|
||||
}
|
||||
|
||||
public function updatedIsImageScanningAllowed(): void
|
||||
{
|
||||
abort_unless(isInstanceAdmin(), 403);
|
||||
$this->validate(['isImageScanningAllowed' => ['required', 'in:1,0']]);
|
||||
|
||||
$this->saveSettings(['is_image_scanning_allowed' => $this->isImageScanningAllowed === '1']);
|
||||
}
|
||||
|
||||
public function saveDatabaseUrl(): void
|
||||
{
|
||||
abort_unless(isInstanceAdmin(), 403);
|
||||
$this->imageScanDbUrl = filled($this->imageScanDbUrl) ? trim($this->imageScanDbUrl) : null;
|
||||
$this->validate(
|
||||
['imageScanDbUrl' => ['nullable', 'url:https', 'max:2048', 'regex:#\Ahttps://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%/+=-]*)?\z#']],
|
||||
['imageScanDbUrl.regex' => 'Use an https URL without a query, a fragment, or credentials.'],
|
||||
);
|
||||
|
||||
$this->saveSettings(['image_scan_db_url' => $this->imageScanDbUrl]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
private function saveSettings(array $values): void
|
||||
{
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->fill($values);
|
||||
$changedFields = auditChangedFields($settings);
|
||||
$settings->save();
|
||||
if ($changedFields !== []) {
|
||||
auditLog('ui.instance.settings.updated', [
|
||||
'team_id' => null,
|
||||
'resource' => 'instance',
|
||||
'section' => 'image_scanning',
|
||||
'changed_fields' => $changedFields,
|
||||
]);
|
||||
}
|
||||
$this->dispatch('success', 'Saved.');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.image-scan.instance-image-scanning');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\ImageScan;
|
||||
|
||||
use App\Models\ImageSbom;
|
||||
use App\Models\ImageScan;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/**
|
||||
* The packages of the images a resource runs, from the stored package lists, with the SBOM downloads.
|
||||
*/
|
||||
class Packages extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use ListensToTeamChannel;
|
||||
use WithPagination;
|
||||
|
||||
private const PAGE_NAME = 'packagesPage';
|
||||
|
||||
#[Locked]
|
||||
public string $resourceType;
|
||||
|
||||
#[Locked]
|
||||
public string $resourceUuid;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/** @var list<string> */
|
||||
public array $types = [];
|
||||
|
||||
/** @var list<string> Image refs, for pages with more than one image. */
|
||||
public array $images = [];
|
||||
|
||||
public int $perPage = 10;
|
||||
|
||||
private ?Model $loadedResource = null;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
return $this->teamChannelListeners([
|
||||
'ImageScanUpdated' => 'scanUpdated',
|
||||
]);
|
||||
}
|
||||
|
||||
public function mount(string $resourceType, string $resourceUuid): void
|
||||
{
|
||||
abort_unless(in_array($resourceType, ResourceScans::RESOURCE_TYPES, true), 404);
|
||||
$this->resourceType = $resourceType;
|
||||
$this->resourceUuid = $resourceUuid;
|
||||
$this->resource();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $event
|
||||
*/
|
||||
public function scanUpdated(array $event = []): void
|
||||
{
|
||||
$uuids = ResourceScans::owners($this->resource())
|
||||
->map(fn (Model $owner): string => (string) $owner->getAttribute('uuid'))
|
||||
->push($this->resourceUuid);
|
||||
|
||||
if (! $uuids->contains((string) data_get($event, 'resourceUuid'))) {
|
||||
$this->skipRender();
|
||||
}
|
||||
}
|
||||
|
||||
public function updated(string $property): void
|
||||
{
|
||||
if (in_array($property, ['search', 'perPage'], true)) {
|
||||
$this->resetPage(self::PAGE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleType(string $type): void
|
||||
{
|
||||
$this->types = in_array($type, $this->types, true)
|
||||
? array_values(array_diff($this->types, [$type]))
|
||||
: [...$this->types, $type];
|
||||
$this->resetPage(self::PAGE_NAME);
|
||||
}
|
||||
|
||||
public function toggleImage(string $image): void
|
||||
{
|
||||
$this->images = in_array($image, $this->images, true)
|
||||
? array_values(array_diff($this->images, [$image]))
|
||||
: [...$this->images, $image];
|
||||
$this->resetPage(self::PAGE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* The packages of one image only, or of all images for an empty image.
|
||||
*/
|
||||
public function showImage(string $image = ''): void
|
||||
{
|
||||
$this->images = $image === '' ? [] : [$image];
|
||||
$this->resetPage(self::PAGE_NAME);
|
||||
}
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->types = [];
|
||||
$this->resetPage(self::PAGE_NAME);
|
||||
}
|
||||
|
||||
private function resource(): Model
|
||||
{
|
||||
if ($this->loadedResource !== null) {
|
||||
return $this->loadedResource;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
abort_unless($user !== null, 403);
|
||||
|
||||
$resource = ResourceScans::find($this->resourceType, $this->resourceUuid, $user->teams->pluck('id')->all());
|
||||
abort_unless($resource instanceof Model, 404);
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
return $this->loadedResource = $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{routeName: string, parameters: array<string, string>}
|
||||
*/
|
||||
private function downloadRoute(Model $resource): array
|
||||
{
|
||||
$environment = $resource->getAttribute('environment');
|
||||
|
||||
return [
|
||||
'routeName' => "project.{$this->resourceType}.vulnerabilities.sbom",
|
||||
'parameters' => [
|
||||
'project_uuid' => (string) data_get($environment, 'project.uuid'),
|
||||
'environment_uuid' => (string) data_get($environment, 'uuid'),
|
||||
"{$this->resourceType}_uuid" => $this->resourceUuid,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$resource = $this->resource();
|
||||
$scans = instanceSettings()->is_image_scanning_allowed
|
||||
? ResourceScans::current($resource, null, ['id', 'uuid', 'resource_type', 'resource_id', 'deployment_queue_id', 'image_sbom_id', 'image_ref', 'scanned_at'])
|
||||
->filter(fn (ImageScan $scan): bool => $scan->image_sbom_id !== null)
|
||||
->values()
|
||||
: collect();
|
||||
|
||||
$sboms = ImageSbom::query()->whereIn('id', $scans->pluck('image_sbom_id')->unique())->get()->keyBy('id');
|
||||
|
||||
/** @var Collection<int, array{key: string, name: string, version: string, type: string, licenses: list<string>, purl: string, image: string}> $packages */
|
||||
$packages = $scans->flatMap(function (ImageScan $scan) use ($sboms): array {
|
||||
$sbom = $sboms->get($scan->image_sbom_id);
|
||||
|
||||
return collect((array) $sbom?->packages)
|
||||
->filter(fn ($row): bool => is_array($row))
|
||||
->map(fn (array $row, int $index): array => [
|
||||
'key' => $scan->uuid.'-'.$index,
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'version' => (string) ($row['version'] ?? ''),
|
||||
'type' => (string) ($row['type'] ?? ''),
|
||||
'licenses' => array_values(array_map('strval', (array) ($row['licenses'] ?? []))),
|
||||
'purl' => (string) ($row['purl'] ?? ''),
|
||||
'image' => (string) $scan->image_ref,
|
||||
])
|
||||
->all();
|
||||
})->values();
|
||||
|
||||
$typeOptions = $packages->pluck('type')->filter()->unique()->sort()->values()->all();
|
||||
$imageOptions = $scans->pluck('image_ref')->map(fn ($ref): string => (string) $ref)->unique()->values()->all();
|
||||
$this->images = array_values(array_intersect($this->images, $imageOptions));
|
||||
$search = mb_strtolower(trim($this->search));
|
||||
$filtered = $packages
|
||||
->when($this->types !== [], fn (Collection $rows) => $rows->filter(fn (array $row): bool => in_array($row['type'], $this->types, true)))
|
||||
->when($this->images !== [], fn (Collection $rows) => $rows->filter(fn (array $row): bool => in_array($row['image'], $this->images, true)))
|
||||
->when($search !== '', fn (Collection $rows) => $rows->filter(fn (array $row): bool => str_contains(mb_strtolower($row['name'].' '.$row['version'].' '.$row['purl']), $search)))
|
||||
->sortBy([['name', 'asc'], ['version', 'asc']])
|
||||
->values();
|
||||
|
||||
$this->perPage = max(1, min(100, $this->perPage));
|
||||
$lastPage = max(1, (int) ceil($filtered->count() / $this->perPage));
|
||||
$page = max(1, min((int) $this->getPage(self::PAGE_NAME), $lastPage));
|
||||
if ($page !== (int) $this->getPage(self::PAGE_NAME)) {
|
||||
$this->setPage($page, self::PAGE_NAME);
|
||||
}
|
||||
|
||||
$route = $this->downloadRoute($resource);
|
||||
|
||||
return view('livewire.image-scan.packages', [
|
||||
'scans' => $scans,
|
||||
'total' => $packages->count(),
|
||||
'items' => new LengthAwarePaginator($filtered->forPage($page, $this->perPage)->values(), $filtered->count(), $this->perPage, $page, ['pageName' => self::PAGE_NAME]),
|
||||
'typeOptions' => $typeOptions,
|
||||
'imageOptions' => count($imageOptions) > 1 ? $imageOptions : [],
|
||||
'showsImage' => $scans->pluck('image_ref')->unique()->count() > 1,
|
||||
'truncated' => $sboms->contains(fn (ImageSbom $sbom): bool => (bool) $sbom->packages_truncated),
|
||||
'downloads' => $scans->map(fn (ImageScan $scan): array => [
|
||||
'image' => (string) $scan->image_ref,
|
||||
'cyclonedx' => route($route['routeName'], [...$route['parameters'], 'scan_uuid' => $scan->uuid, 'format' => 'cyclonedx']),
|
||||
'spdx' => route($route['routeName'], [...$route['parameters'], 'scan_uuid' => $scan->uuid, 'format' => 'spdx']),
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\ImageScan;
|
||||
|
||||
use App\Enums\ImageScanGateMode;
|
||||
use App\Enums\ImageScanOnError;
|
||||
use App\Jobs\RefreshImageScanSummariesJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ImageScan;
|
||||
use App\Models\ImageScanIgnore;
|
||||
use App\Models\ImageScanPolicy;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\Team;
|
||||
use App\Services\Access\AccessResolver;
|
||||
use App\Services\ImageScan\ImageScanPolicyResolver;
|
||||
use App\Services\ImageScan\ResourceScans;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Scan rules of one level (instance, team, server or application), saved on change, and the ignores of that level.
|
||||
* A null value inherits from the level above. Databases and services have no rules of their own: their scope lists
|
||||
* only their ignores (a service with the ignores of its applications and databases). The scope is reloaded and
|
||||
* authorized on every request.
|
||||
*/
|
||||
class ScanSettings extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
private const SCOPES = ['instance', 'team', 'server', 'application', 'database', 'service'];
|
||||
|
||||
/** Scopes that only list their ignores. */
|
||||
private const IGNORE_ONLY_SCOPES = ['database', 'service'];
|
||||
|
||||
private const LEVEL_NAMES = ['instance' => 'Coolify', 'team' => 'team', 'server' => 'server'];
|
||||
|
||||
#[Locked]
|
||||
public string $scopeType;
|
||||
|
||||
/** Server, application, database, or service uuid, or the team id for the team scope (teams have no uuid). */
|
||||
#[Locked]
|
||||
public ?string $scopeUuid = null;
|
||||
|
||||
public ?string $gateMode = '';
|
||||
|
||||
public ?string $onScanError = '';
|
||||
|
||||
public ?string $countMedium = '';
|
||||
|
||||
public ?string $appScanEnabled = '1';
|
||||
|
||||
public ?string $buildsByTeam = '';
|
||||
|
||||
public ?string $scanNewServers = '0';
|
||||
|
||||
public function mount(string $scopeType, ?string $scopeUuid = null): void
|
||||
{
|
||||
abort_unless(in_array($scopeType, self::SCOPES, true), 404);
|
||||
$this->scopeType = $scopeType;
|
||||
$this->scopeUuid = $scopeUuid;
|
||||
|
||||
$scope = $this->loadScope();
|
||||
if ($this->isIgnoreOnly()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$policy = ImageScanPolicyResolver::forScope($scope);
|
||||
$this->gateMode = $policy->gate_mode?->value ?? '';
|
||||
$this->onScanError = $policy->on_scan_error?->value ?? '';
|
||||
$this->countMedium = self::boolToOption($policy->count_medium);
|
||||
|
||||
if ($scope instanceof Application) {
|
||||
$this->appScanEnabled = self::boolToOption((bool) $scope->settings?->is_image_scan_enabled);
|
||||
$this->buildsByTeam = self::boolToOption($scope->settings?->image_builds_by_team);
|
||||
}
|
||||
if ($scope instanceof Team) {
|
||||
$this->scanNewServers = self::boolToOption((bool) $scope->scan_images_on_new_servers);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedGateMode(): void
|
||||
{
|
||||
$this->saveRule('gate_mode', 'gateMode');
|
||||
}
|
||||
|
||||
public function updatedOnScanError(): void
|
||||
{
|
||||
$this->saveRule('on_scan_error', 'onScanError');
|
||||
}
|
||||
|
||||
public function updatedCountMedium(): void
|
||||
{
|
||||
$this->saveRule('count_medium', 'countMedium');
|
||||
}
|
||||
|
||||
public function updatedAppScanEnabled(): void
|
||||
{
|
||||
$application = $this->loadScope();
|
||||
abort_unless($application instanceof Application, 404);
|
||||
$this->authorizeEdit($application);
|
||||
$this->validate(['appScanEnabled' => ['required', 'in:1,0']]);
|
||||
|
||||
$application->settings->is_image_scan_enabled = $this->appScanEnabled === '1';
|
||||
$application->settings->save();
|
||||
$this->audit($application, 'is_image_scan_enabled');
|
||||
$this->dispatch('success', 'Saved.');
|
||||
}
|
||||
|
||||
public function updatedBuildsByTeam(): void
|
||||
{
|
||||
$application = $this->loadScope();
|
||||
abort_unless($application instanceof Application, 404);
|
||||
$this->authorizeEdit($application);
|
||||
$this->validate(['buildsByTeam' => ['nullable', 'in:1,0']]);
|
||||
|
||||
$application->settings->image_builds_by_team = self::optionToBool($this->buildsByTeam);
|
||||
$application->settings->save();
|
||||
$this->audit($application, 'image_builds_by_team');
|
||||
$this->dispatch('success', 'Saved.');
|
||||
}
|
||||
|
||||
public function updatedScanNewServers(): void
|
||||
{
|
||||
$team = $this->loadScope();
|
||||
abort_unless($team instanceof Team, 404);
|
||||
$this->authorizeEdit($team);
|
||||
$this->validate(['scanNewServers' => ['required', 'in:1,0']]);
|
||||
|
||||
$team->scan_images_on_new_servers = $this->scanNewServers === '1';
|
||||
$team->save();
|
||||
$this->audit($team, 'scan_images_on_new_servers');
|
||||
$this->dispatch('success', 'Saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an ignore of this level. On a resource page only the ignores of that resource (for a service: of its
|
||||
* applications and databases); team-wide ignores are removed on the team settings.
|
||||
*/
|
||||
public function removeIgnore(string $uuid): void
|
||||
{
|
||||
$scope = $this->loadScope();
|
||||
$ignore = match (true) {
|
||||
$scope instanceof Team => ImageScanIgnore::query()
|
||||
->where('team_id', $scope->id)
|
||||
->whereNull('resource_id'),
|
||||
$this->isResourceScope($scope) => $this->resourceIgnores($scope),
|
||||
default => null,
|
||||
};
|
||||
$ignore = $ignore?->where('uuid', $uuid)->first();
|
||||
abort_unless($ignore instanceof ImageScanIgnore, 404);
|
||||
$this->authorizeEdit($scope);
|
||||
|
||||
$ignore->delete();
|
||||
if ($ignore->resource_id === null) {
|
||||
RefreshImageScanSummariesJob::dispatch((int) $ignore->team_id);
|
||||
} elseif ($this->isResourceScope($scope)) {
|
||||
ResourceScans::refreshSummaries(ResourceScans::current($scope));
|
||||
}
|
||||
auditLog('ui.image_scan.ignore_removed', [
|
||||
'team_id' => $ignore->team_id,
|
||||
'scope' => $this->scopeType,
|
||||
'scope_uuid' => $this->scopeUuid,
|
||||
'vulnerability_id' => $ignore->vulnerability_id,
|
||||
'package' => $ignore->package,
|
||||
]);
|
||||
$this->dispatch('success', 'Removed.');
|
||||
}
|
||||
|
||||
private function saveRule(string $column, string $property): void
|
||||
{
|
||||
abort_if($this->isIgnoreOnly(), 404);
|
||||
$scope = $this->loadScope();
|
||||
$this->authorizeEdit($scope);
|
||||
|
||||
$rules = ImageScanPolicy::validationRules()[$column];
|
||||
if ($scope === null) {
|
||||
$rules = ['required', ...array_filter($rules, fn ($rule) => $rule !== 'nullable')];
|
||||
}
|
||||
$this->validate([$property => $rules]);
|
||||
|
||||
$policy = ImageScanPolicyResolver::forScope($scope);
|
||||
if ($column === 'gate_mode' && $scope instanceof Application && ! $scope->settings?->is_image_scan_enabled) {
|
||||
$this->gateMode = $policy->gate_mode?->value ?? '';
|
||||
$this->dispatch('error', 'Turn on the scan for this app first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $this->{$property} === '' || $this->{$property} === null ? null : $this->{$property};
|
||||
$policy->{$column} = $column === 'count_medium' ? self::optionToBool($value) : $value;
|
||||
$policy->save();
|
||||
if ($column === 'count_medium') {
|
||||
$this->refreshStatuses($scope);
|
||||
}
|
||||
$this->audit($scope, $column);
|
||||
$this->dispatch('success', 'Saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the stored statuses again after "Count medium problems" changed: of the app now, of the team of a team
|
||||
* or server in a job, and of every team with scans for the instance.
|
||||
*/
|
||||
private function refreshStatuses(?Model $scope): void
|
||||
{
|
||||
if ($scope instanceof Application) {
|
||||
ResourceScans::refreshSummaries(ResourceScans::current($scope));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$teamIds = $scope === null
|
||||
? Server::query()->whereIn('id', ImageScan::query()->select('server_id'))->distinct()->pluck('team_id')->all()
|
||||
: [$this->teamIdOf($scope)];
|
||||
foreach (array_filter($teamIds, fn ($teamId): bool => $teamId !== null) as $teamId) {
|
||||
RefreshImageScanSummariesJob::dispatch((int) $teamId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the scope from the locked identifiers and checks that the user may see it.
|
||||
*/
|
||||
private function loadScope(): ?Model
|
||||
{
|
||||
$user = auth()->user();
|
||||
abort_unless($user !== null, 403);
|
||||
|
||||
if ($this->scopeType === 'instance') {
|
||||
abort_unless(isInstanceAdmin(), 403);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$scope = match ($this->scopeType) {
|
||||
'team' => ctype_digit((string) $this->scopeUuid) ? Team::query()->find((int) $this->scopeUuid) : null,
|
||||
'server' => Server::query()->where('uuid', (string) $this->scopeUuid)->first(),
|
||||
'application' => Application::query()->where('uuid', (string) $this->scopeUuid)->first(),
|
||||
// Found only within the user's teams.
|
||||
'database', 'service' => ResourceScans::find($this->scopeType, (string) $this->scopeUuid, $user->teams->pluck('id')->all()),
|
||||
};
|
||||
$teamId = $this->teamIdOf($scope);
|
||||
abort_unless($teamId !== null && $user->teams->contains('id', $teamId), 404);
|
||||
|
||||
if ($this->isResourceScope($scope)) {
|
||||
$this->authorize('view', $scope);
|
||||
}
|
||||
|
||||
return $scope;
|
||||
}
|
||||
|
||||
private function isIgnoreOnly(): bool
|
||||
{
|
||||
return in_array($this->scopeType, self::IGNORE_ONLY_SCOPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* An application, database, or service: a resource with its own Security page.
|
||||
*/
|
||||
private function isResourceScope(?Model $scope): bool
|
||||
{
|
||||
return $scope instanceof Application || ($scope !== null && in_array($this->scopeType, self::IGNORE_ONLY_SCOPES, true));
|
||||
}
|
||||
|
||||
private function teamIdOf(?Model $scope): ?int
|
||||
{
|
||||
return match (true) {
|
||||
$scope instanceof Team => $scope->id,
|
||||
$scope instanceof Server => $scope->team_id,
|
||||
$scope !== null && method_exists($scope, 'team') => $scope->team()?->id,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ignores of a resource: of an application or database itself, of the applications and databases of a service
|
||||
* (the Security page sets them there), and of the service.
|
||||
*
|
||||
* @return Builder<ImageScanIgnore>
|
||||
*/
|
||||
private function resourceIgnores(Model $scope): Builder
|
||||
{
|
||||
$owners = $scope instanceof Service ? ResourceScans::owners($scope)->push($scope) : collect([$scope]);
|
||||
|
||||
return ImageScanIgnore::query()
|
||||
->where('team_id', $this->teamIdOf($scope))
|
||||
->where(fn (Builder $query) => $owners->each(fn (Model $owner) => $query->orWhere(fn (Builder $query) => $query
|
||||
->where('resource_type', $owner->getMorphClass())
|
||||
->where('resource_id', $owner->getKey()))));
|
||||
}
|
||||
|
||||
private function authorizeEdit(?Model $scope): void
|
||||
{
|
||||
match (true) {
|
||||
$scope === null => abort_unless(isInstanceAdmin(), 403),
|
||||
$scope instanceof Team => abort_unless(auth()->user()->isAdminOfTeam($scope->id), 403),
|
||||
default => $this->authorize('update', $scope),
|
||||
};
|
||||
}
|
||||
|
||||
private function canEdit(?Model $scope): bool
|
||||
{
|
||||
return match (true) {
|
||||
$scope === null => isInstanceAdmin(),
|
||||
$scope instanceof Team => auth()->user()->isAdminOfTeam($scope->id),
|
||||
default => auth()->user()->can('update', $scope),
|
||||
};
|
||||
}
|
||||
|
||||
private function audit(?Model $scope, string $field): void
|
||||
{
|
||||
auditLog('ui.image_scan.settings_updated', [
|
||||
'team_id' => $this->teamIdOf($scope),
|
||||
'scope' => $this->scopeType,
|
||||
'scope_uuid' => $this->scopeUuid,
|
||||
'changed_fields' => [$field],
|
||||
]);
|
||||
}
|
||||
|
||||
private static function boolToOption(?bool $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
true => '1',
|
||||
false => '0',
|
||||
null => '',
|
||||
};
|
||||
}
|
||||
|
||||
private static function optionToBool(?string $value): ?bool
|
||||
{
|
||||
return match ($value) {
|
||||
'1' => true,
|
||||
'0' => false,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>|null Inherit option label per rule field, null for the instance level.
|
||||
*/
|
||||
private function inheritLabels(?Model $scope): ?array
|
||||
{
|
||||
if ($scope === null || $this->isIgnoreOnly()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$inherited = ImageScanPolicyResolver::inheritedFor($scope);
|
||||
$label = fn (string $field, string $valueLabel) => 'Use '.self::LEVEL_NAMES[$inherited[$field]['source']].' default ('.$valueLabel.')';
|
||||
|
||||
return [
|
||||
'gate_mode' => $label('gate_mode', $inherited['gate_mode']['value']->label()),
|
||||
'on_scan_error' => $label('on_scan_error', $inherited['on_scan_error']['value']->label()),
|
||||
'count_medium' => $label('count_medium', $inherited['count_medium']['value'] ? 'Yes' : 'No'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ImageScanIgnore>
|
||||
*/
|
||||
private function ignores(?Model $scope): Collection
|
||||
{
|
||||
return match (true) {
|
||||
$scope instanceof Team => ImageScanIgnore::query()
|
||||
->where('team_id', $scope->id)
|
||||
->whereNull('resource_id')
|
||||
->latest()
|
||||
->get(),
|
||||
$this->isResourceScope($scope) => ImageScanIgnore::query()
|
||||
->where('team_id', $this->teamIdOf($scope))
|
||||
->where(fn (Builder $query) => $query
|
||||
->whereNull('resource_id')
|
||||
->orWhereIn('id', $this->resourceIgnores($scope)->select('id')))
|
||||
->orderByRaw('resource_id is null')
|
||||
->latest()
|
||||
->get(),
|
||||
default => collect(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* For a service: the name of the application or database each ignore was set for.
|
||||
*
|
||||
* @param Collection<int, ImageScanIgnore> $ignores
|
||||
* @return array<string, string> By ignore uuid.
|
||||
*/
|
||||
private function ownerNames(?Model $scope, Collection $ignores): array
|
||||
{
|
||||
if (! $scope instanceof Service) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = ResourceScans::owners($scope)->mapWithKeys(fn (Model $owner): array => [
|
||||
$owner->getMorphClass().':'.$owner->getKey() => (string) ($owner->getAttribute('human_name') ?: $owner->getAttribute('name')),
|
||||
]);
|
||||
|
||||
return $ignores
|
||||
->filter(fn (ImageScanIgnore $ignore): bool => $ignore->resource_id !== null && $names->has($ignore->resource_type.':'.$ignore->resource_id))
|
||||
->mapWithKeys(fn (ImageScanIgnore $ignore): array => [(string) $ignore->uuid => $names->get($ignore->resource_type.':'.$ignore->resource_id)])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* The team settings page opens the session team, so the link is shown only when that is the team of the
|
||||
* resource and the user can open team-wide pages.
|
||||
*/
|
||||
private function showsTeamSettingsLink(?Model $scope): bool
|
||||
{
|
||||
$teamId = $this->isResourceScope($scope) ? $this->teamIdOf($scope) : null;
|
||||
|
||||
return $teamId !== null
|
||||
&& currentTeam()?->id === $teamId
|
||||
&& app(AccessResolver::class)->coversWholeTeam(auth()->user(), $teamId);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$scope = $this->loadScope();
|
||||
$ignores = $this->ignores($scope);
|
||||
|
||||
return view('livewire.image-scan.scan-settings', [
|
||||
'canEdit' => $this->canEdit($scope),
|
||||
'inheritLabels' => $this->inheritLabels($scope),
|
||||
'gateModeOptions' => collect(ImageScanGateMode::cases())
|
||||
->map(fn (ImageScanGateMode $mode) => array_filter(['value' => $mode->value, 'label' => $mode->label(), 'description' => $mode->description()], fn ($value) => $value !== null))
|
||||
->all(),
|
||||
'onScanErrorOptions' => collect(ImageScanOnError::cases())
|
||||
->map(fn (ImageScanOnError $option) => ['value' => $option->value, 'label' => $option->label()])
|
||||
->all(),
|
||||
'countMediumOptions' => [['value' => '1', 'label' => 'Yes'], ['value' => '0', 'label' => 'No']],
|
||||
'appScanOn' => $scope instanceof Application ? (bool) $scope->settings?->is_image_scan_enabled : true,
|
||||
'isDockerImage' => $scope instanceof Application && $scope->build_pack === 'dockerimage',
|
||||
'showsIgnores' => $scope instanceof Team || $this->isResourceScope($scope),
|
||||
'showsTeamSettingsLink' => $this->showsTeamSettingsLink($scope),
|
||||
'ignores' => $ignores,
|
||||
'ownerNames' => $this->ownerNames($scope, $ignores),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\ImageScan;
|
||||
|
||||
use App\Actions\ImageScan\SetServerImageScanning;
|
||||
use App\Jobs\PrepareImageScannerJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Services\ImageScan\ImageRef;
|
||||
use App\Services\ImageScan\ImageScanningRefused;
|
||||
use App\Services\ImageScan\ScannerCommands;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
|
||||
class ServerImageScanning extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use ListensToTeamChannel;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
return $this->teamChannelListeners([
|
||||
'ImageScanUpdated' => 'scanUpdated',
|
||||
]);
|
||||
}
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->authorize('viewSecurity', $this->server);
|
||||
}
|
||||
|
||||
public function scanUpdated(array $event = []): void
|
||||
{
|
||||
$resourceUuid = data_get($event, 'resourceUuid');
|
||||
if ($resourceUuid !== $this->server->uuid || data_get($event, 'scanUuid') !== PrepareImageScannerJob::SCAN_UUID) {
|
||||
$this->skipRender();
|
||||
}
|
||||
}
|
||||
|
||||
public function enable()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
try {
|
||||
SetServerImageScanning::run($this->server, true);
|
||||
$this->auditImageScanning('image_scanning_enabled');
|
||||
$this->dispatch('success', 'Image scanning turned on. Coolify is getting the scanner.');
|
||||
} catch (ImageScanningRefused $e) {
|
||||
$this->dispatch('error', message: $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function prepareScanner()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
try {
|
||||
SetServerImageScanning::ensureManagedHere($this->server);
|
||||
if (! InstanceSettings::get()->is_image_scanning_allowed || ! $this->server->settings->refresh()->is_image_scan_enabled) {
|
||||
$this->dispatch('error', message: 'Turn on image scanning first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SetServerImageScanning::prepareScanner($this->server);
|
||||
$this->dispatch('success', 'Coolify is getting the scanner.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function disable()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
try {
|
||||
SetServerImageScanning::run($this->server, false);
|
||||
$this->auditImageScanning('image_scanning_disabled');
|
||||
$this->dispatch('success', 'Image scanning turned off.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteScannerData()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
try {
|
||||
SetServerImageScanning::ensureManagedHere($this->server);
|
||||
$commands = new ScannerCommands($this->server, Str::lower(Str::random(24)));
|
||||
instant_remote_process($commands->deleteScannerData(), $this->server, throwError: true, timeout: 120);
|
||||
PrepareImageScannerJob::forget($this->server);
|
||||
$this->auditImageScanning('image_scanner_data_deleted');
|
||||
$this->dispatch('success', 'Scanner data deleted.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this, 'Could not delete the scanner data.');
|
||||
}
|
||||
}
|
||||
|
||||
private function auditImageScanning(string $action): void
|
||||
{
|
||||
auditLog("ui.server.{$action}", [
|
||||
'team_id' => $this->server->team_id,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$state = Cache::get(PrepareImageScannerJob::stateCacheKey($this->server));
|
||||
$builtAt = Cache::get(PrepareImageScannerJob::builtAtCacheKey($this->server));
|
||||
$failedStep = Cache::get(PrepareImageScannerJob::failedStepCacheKey($this->server));
|
||||
$diskUsed = Cache::get(PrepareImageScannerJob::diskUsedCacheKey($this->server));
|
||||
|
||||
return view('livewire.image-scan.server-image-scanning', [
|
||||
'allowed' => (bool) InstanceSettings::get()->is_image_scanning_allowed,
|
||||
'enabled' => (bool) $this->server->settings->is_image_scan_enabled,
|
||||
'prepareState' => is_string($state) ? $state : null,
|
||||
'failedStep' => is_string($failedStep) ? $failedStep : null,
|
||||
'registries' => collect(config('constants.image_scan.images'))
|
||||
->map(fn (array $image) => ImageRef::parse($image['repo'])->registry)
|
||||
->unique()
|
||||
->implode(', '),
|
||||
'diskUsed' => is_int($diskUsed) ? formatBytes($diskUsed, 1) : null,
|
||||
'toolVersions' => collect(['syft' => 'Syft', 'grype' => 'Grype', 'regctl' => 'regctl'])
|
||||
->map(fn (string $name, string $tool) => $name.' '.config("constants.image_scan.images.{$tool}.version"))
|
||||
->implode(', '),
|
||||
'builtAt' => is_string($builtAt) ? CarbonImmutable::parse($builtAt) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\ImageScan;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Team settings page for image scanning. Members see the values, admins edit them (authorized in ScanSettings).
|
||||
*/
|
||||
class TeamImageScanning extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public int $teamId;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$team = currentTeam();
|
||||
abort_unless($team instanceof Team && auth()->user()->teams->contains('id', $team->id), 403);
|
||||
$this->teamId = $team->id;
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.image-scan.team-image-scanning', [
|
||||
'allowed' => (bool) InstanceSettings::get()->is_image_scanning_allowed,
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ trait TogglesNotificationEvents
|
||||
'serverUnreachable',
|
||||
'serverPatch',
|
||||
'traefikOutdated',
|
||||
'imageVulnerabilities',
|
||||
];
|
||||
|
||||
public function toggleEvent(string $property): void
|
||||
|
||||
@@ -70,6 +70,9 @@ class Discord extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedDiscordNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $imageVulnerabilitiesDiscordNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $discordPingEnabled = true;
|
||||
|
||||
@@ -107,6 +110,7 @@ class Discord extends Component
|
||||
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
|
||||
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
|
||||
$this->settings->traefik_outdated_discord_notifications = $this->traefikOutdatedDiscordNotifications;
|
||||
$this->settings->image_vulnerabilities_discord_notifications = $this->imageVulnerabilitiesDiscordNotifications;
|
||||
|
||||
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
|
||||
|
||||
@@ -135,6 +139,7 @@ class Discord extends Component
|
||||
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
|
||||
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
|
||||
$this->traefikOutdatedDiscordNotifications = $this->settings->traefik_outdated_discord_notifications;
|
||||
$this->imageVulnerabilitiesDiscordNotifications = $this->settings->image_vulnerabilities_discord_notifications;
|
||||
|
||||
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
|
||||
}
|
||||
|
||||
@@ -2,20 +2,26 @@
|
||||
|
||||
namespace App\Livewire\Notifications;
|
||||
|
||||
use App\Livewire\Concerns\ManagesSmtpServers;
|
||||
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Test;
|
||||
use App\Rules\SafeSmtpHost;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Support\SmtpPresets;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class Email extends Component
|
||||
{
|
||||
use AuthorizesRequests, TogglesNotificationEvents;
|
||||
use AuthorizesRequests, ManagesSmtpServers, TogglesNotificationEvents;
|
||||
|
||||
protected $listeners = ['refresh' => '$refresh'];
|
||||
|
||||
@@ -67,6 +73,15 @@ class Email extends Component
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $resendApiKey = null;
|
||||
|
||||
#[Validate(['nullable', 'email'])]
|
||||
public ?string $resendFromAddress = null;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $resendFromName = null;
|
||||
|
||||
#[Validate(['required', 'in:none,smtp,resend'])]
|
||||
public string $emailProvider = 'none';
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $useInstanceEmailSettings = false;
|
||||
|
||||
@@ -118,6 +133,9 @@ class Email extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedEmailNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $imageVulnerabilitiesEmailNotifications = true;
|
||||
|
||||
#[Validate(['nullable', 'email'])]
|
||||
public ?string $testEmailAddress = null;
|
||||
|
||||
@@ -140,20 +158,17 @@ class Email extends Component
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
|
||||
if ($this->smtpHost !== $this->settings->smtp_host) {
|
||||
$this->validate(['smtpHost' => ['nullable', 'string', new SafeSmtpHost]]);
|
||||
}
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled;
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||
$this->settings->smtp_recipients = $this->smtpRecipients;
|
||||
$this->settings->smtp_host = $this->smtpHost;
|
||||
$this->settings->smtp_port = $this->smtpPort;
|
||||
$this->settings->smtp_encryption = $this->smtpEncryption;
|
||||
$this->settings->smtp_username = $this->smtpUsername;
|
||||
$this->settings->smtp_password = $this->smtpPassword;
|
||||
$this->settings->smtp_timeout = $this->smtpTimeout;
|
||||
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
|
||||
$this->fillSmtpServerSettings();
|
||||
|
||||
$this->settings->resend_enabled = $this->resendEnabled;
|
||||
$this->settings->resend_api_key = $this->resendApiKey;
|
||||
$this->settings->resend_from_address = $this->resendFromAddress;
|
||||
$this->settings->resend_from_name = $this->resendFromName;
|
||||
|
||||
$this->settings->use_instance_email_settings = $this->useInstanceEmailSettings;
|
||||
|
||||
@@ -173,9 +188,8 @@ class Email extends Component
|
||||
$this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications;
|
||||
$this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications;
|
||||
$this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications;
|
||||
$changedFields = array_keys($this->settings->getDirty());
|
||||
$this->settings->save();
|
||||
$this->auditNotificationSettings($changedFields);
|
||||
$this->settings->image_vulnerabilities_email_notifications = $this->imageVulnerabilitiesEmailNotifications;
|
||||
$this->saveEmailSettings();
|
||||
|
||||
} else {
|
||||
$this->smtpEnabled = $this->settings->smtp_enabled;
|
||||
@@ -193,11 +207,17 @@ class Email extends Component
|
||||
$this->smtpEhloDomain = $this->settings->smtp_ehlo_domain;
|
||||
|
||||
$this->resendEnabled = $this->settings->resend_enabled;
|
||||
$this->resendFromAddress = $this->settings->resend_from_address;
|
||||
$this->resendFromName = $this->settings->resend_from_name;
|
||||
$this->resendApiKey = auth()->user()->can('update', $this->settings)
|
||||
? $this->settings->resend_api_key
|
||||
: null;
|
||||
|
||||
$this->smtpServerName = $this->settings->smtp_server_name;
|
||||
$this->selectedSmtpServer = 'active';
|
||||
|
||||
$this->useInstanceEmailSettings = $this->settings->use_instance_email_settings;
|
||||
$this->emailProvider = $this->activeEmailProvider();
|
||||
|
||||
$this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications;
|
||||
$this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications;
|
||||
@@ -215,6 +235,7 @@ class Email extends Component
|
||||
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
|
||||
$this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications;
|
||||
$this->traefikOutdatedEmailNotifications = $this->settings->traefik_outdated_email_notifications;
|
||||
$this->imageVulnerabilitiesEmailNotifications = $this->settings->image_vulnerabilities_email_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +243,77 @@ class Email extends Component
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
if (! $this->useInstanceEmailSettings) {
|
||||
$this->enableSelectedEmailProvider();
|
||||
}
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->restoreSavedEmailProvider();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on the provider selected in the provider list and save. When a required field of the
|
||||
* provider is empty, nothing is saved and the page only shows the fields to fill in.
|
||||
*/
|
||||
public function selectEmailProvider()
|
||||
{
|
||||
try {
|
||||
$this->enableSelectedEmailProvider();
|
||||
} catch (ValidationException) {
|
||||
// Not complete yet: show the fields of the provider and the "Not active yet" notice, without an error.
|
||||
$this->resetErrorBag();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->submit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider that sends the team's email now. It differs from $emailProvider until the selected provider is saved.
|
||||
*/
|
||||
#[Computed]
|
||||
public function activeEmailProvider(): string
|
||||
{
|
||||
return enabled_email_provider($this->settings) ?? 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on the provider selected in the provider list. When its settings are not valid, the saved
|
||||
* provider stays on, so that a later save of another field cannot turn off the working provider.
|
||||
*/
|
||||
private function enableSelectedEmailProvider(): void
|
||||
{
|
||||
$this->smtpEnabled = $this->emailProvider === 'smtp';
|
||||
$this->resendEnabled = $this->emailProvider === 'resend';
|
||||
|
||||
try {
|
||||
$this->validateEnabledEmailProvider();
|
||||
} catch (ValidationException $e) {
|
||||
$this->restoreSavedEmailProvider();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function restoreSavedEmailProvider(): void
|
||||
{
|
||||
$this->smtpEnabled = (bool) $this->settings->getOriginal('smtp_enabled');
|
||||
$this->resendEnabled = (bool) $this->settings->getOriginal('resend_enabled');
|
||||
}
|
||||
|
||||
private function validateEnabledEmailProvider(): void
|
||||
{
|
||||
match (true) {
|
||||
$this->smtpEnabled => $this->validateSmtpSettings(),
|
||||
$this->resendEnabled => $this->validateResendSettings(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
@@ -288,7 +374,6 @@ class Email extends Component
|
||||
} else {
|
||||
$this->validateSmtpSettings();
|
||||
$this->smtpEnabled = true;
|
||||
$this->resendEnabled = false;
|
||||
$this->submitSmtp();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -311,7 +396,6 @@ class Email extends Component
|
||||
} else {
|
||||
$this->validateResendSettings();
|
||||
$this->resendEnabled = true;
|
||||
$this->smtpEnabled = false;
|
||||
$this->submitResend();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -332,23 +416,13 @@ class Email extends Component
|
||||
$this->validateSmtpSettings();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->settings->resend_enabled = $this->resendEnabled = false;
|
||||
$this->disableEmailProvidersExcept('smtp');
|
||||
}
|
||||
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled;
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||
$this->settings->smtp_host = $this->smtpHost;
|
||||
$this->settings->smtp_port = $this->smtpPort;
|
||||
$this->settings->smtp_encryption = $this->smtpEncryption;
|
||||
$this->settings->smtp_username = $this->smtpUsername;
|
||||
$this->settings->smtp_password = $this->smtpPassword;
|
||||
$this->settings->smtp_timeout = $this->smtpTimeout;
|
||||
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
|
||||
$this->fillSmtpServerSettings();
|
||||
|
||||
$changedFields = array_keys($this->settings->getDirty());
|
||||
$this->settings->save();
|
||||
$this->auditNotificationSettings($changedFields);
|
||||
$this->saveEmailSettings();
|
||||
$this->dispatch('success', 'SMTP settings updated.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->smtpEnabled = false;
|
||||
@@ -365,34 +439,52 @@ class Email extends Component
|
||||
$this->resetErrorBag();
|
||||
$this->validateResendSettings();
|
||||
if ($this->resendEnabled) {
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled = false;
|
||||
$this->disableEmailProvidersExcept('resend');
|
||||
}
|
||||
|
||||
$this->settings->resend_enabled = $this->resendEnabled;
|
||||
$this->settings->resend_api_key = $this->resendApiKey;
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||
$this->settings->resend_from_address = $this->resendFromAddress;
|
||||
$this->settings->resend_from_name = $this->resendFromName;
|
||||
|
||||
$changedFields = array_keys($this->settings->getDirty());
|
||||
$this->settings->save();
|
||||
$this->auditNotificationSettings($changedFields);
|
||||
$this->saveEmailSettings();
|
||||
$this->dispatch('success', 'Resend settings updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn off every email provider except the given one, so only one provider sends email.
|
||||
*/
|
||||
private function disableEmailProvidersExcept(string $provider): void
|
||||
{
|
||||
if ($provider !== 'smtp') {
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled = false;
|
||||
}
|
||||
if ($provider !== 'resend') {
|
||||
$this->settings->resend_enabled = $this->resendEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function loadEmailSettings(): void
|
||||
{
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
private function validateSmtpSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpServerName' => 'nullable|string|max:255',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
// A stored host is checked again at send time, so an unchanged host must not block saving other fields.
|
||||
'smtpHost' => $this->smtpHost === $this->settings->smtp_host ? ['required', 'string'] : ['required', 'string', new SafeSmtpHost],
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpUsername' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'string'],
|
||||
'smtpPassword' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'required_with:smtpUsername', 'string'],
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
@@ -402,6 +494,9 @@ class Email extends Component
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpUsername.required' => 'This provider needs a username.',
|
||||
'smtpPassword.required' => 'This provider needs a password.',
|
||||
'smtpPassword.required_with' => 'Enter the password for this username.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
}
|
||||
@@ -411,13 +506,13 @@ class Email extends Component
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'resendFromAddress' => $this->resendEnabled ? 'required|email' : 'nullable|email',
|
||||
'resendFromName' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'resendFromAddress.required' => 'From Address is required.',
|
||||
'resendFromAddress.email' => 'Please enter a valid email address.',
|
||||
'resendFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -450,44 +545,20 @@ class Email extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function copyFromInstanceSettings()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
$settings = instanceSettings();
|
||||
$this->authorize('view', $settings);
|
||||
$this->smtpFromAddress = $settings->smtp_from_address;
|
||||
$this->smtpFromName = $settings->smtp_from_name;
|
||||
|
||||
if ($settings->smtp_enabled) {
|
||||
$this->smtpEnabled = true;
|
||||
$this->resendEnabled = false;
|
||||
}
|
||||
|
||||
$this->smtpRecipients = $settings->smtp_recipients;
|
||||
$this->smtpHost = $settings->smtp_host;
|
||||
$this->smtpPort = $settings->smtp_port;
|
||||
$this->smtpEncryption = $settings->smtp_encryption;
|
||||
$this->smtpUsername = $settings->smtp_username;
|
||||
$this->smtpPassword = $settings->smtp_password;
|
||||
$this->smtpTimeout = $settings->smtp_timeout;
|
||||
$this->smtpEhloDomain = $settings->smtp_ehlo_domain;
|
||||
|
||||
if ($settings->resend_enabled) {
|
||||
$this->resendEnabled = true;
|
||||
$this->smtpEnabled = false;
|
||||
}
|
||||
$this->resendApiKey = $settings->resend_api_key;
|
||||
$this->saveModel();
|
||||
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.notifications.email');
|
||||
}
|
||||
|
||||
private function auditNotificationSettings(array $changedFields): void
|
||||
/**
|
||||
* Save the settings, record the changed field names (never secret values), and show the saved provider.
|
||||
*/
|
||||
protected function saveEmailSettings(): void
|
||||
{
|
||||
$changedFields = array_keys($this->settings->getDirty());
|
||||
$this->settings->save();
|
||||
$this->emailProvider = $this->activeEmailProvider();
|
||||
|
||||
if ($changedFields !== []) {
|
||||
auditLog('ui.notifications.email.updated', [
|
||||
'team_id' => $this->team->id,
|
||||
|
||||
@@ -77,6 +77,9 @@ class Pushover extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedPushoverNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $imageVulnerabilitiesPushoverNotifications = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -112,6 +115,7 @@ class Pushover extends Component
|
||||
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
|
||||
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
|
||||
$this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications;
|
||||
$this->settings->image_vulnerabilities_pushover_notifications = $this->imageVulnerabilitiesPushoverNotifications;
|
||||
|
||||
$changedFields = array_keys($this->settings->getDirty());
|
||||
$this->settings->save();
|
||||
@@ -142,6 +146,7 @@ class Pushover extends Component
|
||||
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
|
||||
$this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications;
|
||||
$this->traefikOutdatedPushoverNotifications = $this->settings->traefik_outdated_pushover_notifications;
|
||||
$this->imageVulnerabilitiesPushoverNotifications = $this->settings->image_vulnerabilities_pushover_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ class Slack extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedSlackNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $imageVulnerabilitiesSlackNotifications = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -109,6 +112,7 @@ class Slack extends Component
|
||||
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
|
||||
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
|
||||
$this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications;
|
||||
$this->settings->image_vulnerabilities_slack_notifications = $this->imageVulnerabilitiesSlackNotifications;
|
||||
|
||||
$changedFields = array_keys($this->settings->getDirty());
|
||||
$this->settings->save();
|
||||
@@ -135,6 +139,7 @@ class Slack extends Component
|
||||
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
|
||||
$this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications;
|
||||
$this->traefikOutdatedSlackNotifications = $this->settings->traefik_outdated_slack_notifications;
|
||||
$this->imageVulnerabilitiesSlackNotifications = $this->settings->image_vulnerabilities_slack_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,9 @@ class Telegram extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedTelegramNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $imageVulnerabilitiesTelegramNotifications = false;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
|
||||
|
||||
@@ -123,6 +126,9 @@ class Telegram extends Component
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsTraefikOutdatedThreadId = null;
|
||||
|
||||
#[Validate(['nullable', 'string', 'max:255'])]
|
||||
public ?string $telegramNotificationsImageVulnerabilitiesThreadId = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -158,6 +164,7 @@ class Telegram extends Component
|
||||
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
|
||||
$this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications;
|
||||
$this->settings->traefik_outdated_telegram_notifications = $this->traefikOutdatedTelegramNotifications;
|
||||
$this->settings->image_vulnerabilities_telegram_notifications = $this->imageVulnerabilitiesTelegramNotifications;
|
||||
|
||||
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
|
||||
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
|
||||
@@ -174,6 +181,7 @@ class Telegram extends Component
|
||||
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
|
||||
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
|
||||
$this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId;
|
||||
$this->settings->telegram_notifications_image_vulnerabilities_thread_id = $this->telegramNotificationsImageVulnerabilitiesThreadId;
|
||||
|
||||
$changedFields = array_keys($this->settings->getDirty());
|
||||
$this->settings->save();
|
||||
@@ -198,6 +206,7 @@ class Telegram extends Component
|
||||
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
|
||||
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
|
||||
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
|
||||
$this->telegramNotificationsImageVulnerabilitiesThreadId = $this->settings->telegram_notifications_image_vulnerabilities_thread_id;
|
||||
} else {
|
||||
$this->telegramToken = null;
|
||||
$this->telegramChatId = null;
|
||||
@@ -218,6 +227,7 @@ class Telegram extends Component
|
||||
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
|
||||
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
|
||||
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
|
||||
$this->imageVulnerabilitiesTelegramNotifications = $this->settings->image_vulnerabilities_telegram_notifications;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ class Webhook extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedWebhookNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $imageVulnerabilitiesWebhookNotifications = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -104,6 +107,7 @@ class Webhook extends Component
|
||||
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
|
||||
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
|
||||
$this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications;
|
||||
$this->settings->image_vulnerabilities_webhook_notifications = $this->imageVulnerabilitiesWebhookNotifications;
|
||||
|
||||
$changedFields = array_keys($this->settings->getDirty());
|
||||
$this->settings->save();
|
||||
@@ -130,6 +134,7 @@ class Webhook extends Component
|
||||
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
|
||||
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
|
||||
$this->traefikOutdatedWebhookNotifications = $this->settings->traefik_outdated_webhook_notifications;
|
||||
$this->imageVulnerabilitiesWebhookNotifications = $this->settings->image_vulnerabilities_webhook_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,13 +116,10 @@ class Index extends Component
|
||||
}
|
||||
|
||||
// For self-hosted, check if email is enabled
|
||||
if (! isCloud()) {
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->smtp_enabled && ! $settings->resend_enabled) {
|
||||
$this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.');
|
||||
if (! isCloud() && ! is_transactional_emails_enabled()) {
|
||||
$this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.');
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
namespace App\Livewire\Project\Application\Deployment;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Services\ImageScan\SecurityCard;
|
||||
use App\Traits\AuditsApplicationSettings;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
@@ -66,7 +71,7 @@ class Show extends Component
|
||||
$this->application_deployment_queue = $application_deployment_queue;
|
||||
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
||||
$this->deployment_uuid = $deploymentUuid;
|
||||
$this->is_debug_enabled = auth()->user()->isMember()
|
||||
$this->is_debug_enabled = ! auth()->user()->can('update', $this->application)
|
||||
? false
|
||||
: $this->application->settings->is_debug_enabled;
|
||||
$this->isKeepAliveOn();
|
||||
@@ -108,11 +113,102 @@ class Show extends Component
|
||||
$this->saveApplicationSettingsWithAudit($this->application);
|
||||
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
|
||||
$this->application_deployment_queue->refresh();
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the blocked deployment again for the same commit, with the image scan gate bypassed.
|
||||
*/
|
||||
public function deployAnyway()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
try {
|
||||
$deployment = ApplicationDeploymentQueue::query()
|
||||
->whereKey($this->application_deployment_queue->getKey())
|
||||
->where('application_id', $this->application->id)
|
||||
->firstOrFail();
|
||||
|
||||
if (! $deployment->isImageScanBlocked()) {
|
||||
$this->dispatch('error', 'This deployment was not blocked.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latestDeploymentId = ApplicationDeploymentQueue::query()
|
||||
->where('application_id', $this->application->id)
|
||||
->where('pull_request_id', $deployment->pull_request_id)
|
||||
->max('id');
|
||||
if ((int) $latestDeploymentId !== (int) $deployment->id) {
|
||||
$this->dispatch('error', 'A newer deployment exists.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The same server and destination as the blocked deployment, and the image it blocked.
|
||||
$server = $this->application->deploymentServer($deployment);
|
||||
$destination = $server === null ? null : StandaloneDocker::query()
|
||||
->whereKey($deployment->destination_id)
|
||||
->where('server_id', $server->id)
|
||||
->first();
|
||||
$pinnedImage = data_get($deployment->scan_gate_result, 'repo_digest');
|
||||
|
||||
$deploymentUuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
deployment_uuid: $deploymentUuid,
|
||||
pull_request_id: $deployment->pull_request_id,
|
||||
commit: $deployment->commit,
|
||||
force_rebuild: false,
|
||||
git_type: $deployment->git_type,
|
||||
server: $server,
|
||||
destination: $destination,
|
||||
only_this_server: (bool) $deployment->only_this_server,
|
||||
docker_registry_image_tag: $deployment->docker_registry_image_tag,
|
||||
scan_gate_bypassed_by: auth()->id(),
|
||||
scan_gate_pinned_image: $this->application->build_pack === 'dockerimage' && is_string($pinnedImage) ? $pinnedImage : null,
|
||||
);
|
||||
if ($result['status'] !== 'queued') {
|
||||
$this->dispatch('error', 'Deploy did not start.', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('success', 'Deploy started.');
|
||||
|
||||
return redirectRoute($this, 'project.application.deployment.show', [
|
||||
'project_uuid' => $this->application->environment->project->uuid,
|
||||
'environment_uuid' => $this->application->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'deployment_uuid' => $deploymentUuid,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate result of the shown deployment, for the log toolbar. Null when the gate passed or did not run. `action` is
|
||||
* the sentence of the action that fixes the blocked image, from the Security page.
|
||||
*
|
||||
* @return array{result: array<string, mixed>, blocked: bool, action: ?string}|null
|
||||
*/
|
||||
public function getImageScanGateProperty(): ?array
|
||||
{
|
||||
$deployment = $this->application_deployment_queue;
|
||||
if (! $deployment->isImageScanBlocked() && ! $deployment->isImageScanSkipped()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'result' => $deployment->scan_gate_result,
|
||||
'blocked' => $deployment->isImageScanBlocked(),
|
||||
'action' => $deployment->isImageScanBlocked() ? SecurityCard::gateAction($this->application, $deployment)['sentence'] ?? null : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshQueue()
|
||||
{
|
||||
$this->application_deployment_queue->refresh();
|
||||
@@ -140,6 +236,24 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Source page URL when a git command of Coolify failed because the Git host sent another SSH host key.
|
||||
* Only failed deployments are checked, so the logs are not searched on each poll of a running deployment.
|
||||
*/
|
||||
public function getHostKeyChangedSourceUrlProperty(): ?string
|
||||
{
|
||||
if ($this->application_deployment_queue->status !== ApplicationDeploymentStatus::FAILED->value
|
||||
|| ! str_contains((string) $this->application_deployment_queue->logs, ApplicationDeploymentJob::GIT_HOST_KEY_CHANGED_HINT)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('project.application.source', [
|
||||
'project_uuid' => $this->application->environment->project->uuid,
|
||||
'environment_uuid' => $this->application->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getLogLinesProperty()
|
||||
{
|
||||
return decode_remote_command_output($this->application_deployment_queue);
|
||||
|
||||
@@ -29,7 +29,7 @@ class DeploymentNavbar extends Component
|
||||
{
|
||||
$this->application = Application::ownedByCurrentTeam()->find($this->application_deployment_queue->application_id);
|
||||
$this->server = $this->application->destination->server;
|
||||
$this->is_debug_enabled = auth()->user()->isMember()
|
||||
$this->is_debug_enabled = ! auth()->user()->can('update', $this->application)
|
||||
? false
|
||||
: $this->application->settings->is_debug_enabled;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ use App\Livewire\Project\Service\Storage;
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\All;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Services\ImageScan\ImageScanPolicyResolver;
|
||||
use App\Services\ImageScan\SecurityCard;
|
||||
use App\Traits\ListensToTeamChannel;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -157,6 +160,44 @@ class Heading extends Component
|
||||
->value('deployment_url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate warning for the latest deployment of the application (not a preview) when the image scan
|
||||
* gate blocked it or could not run. Re-evaluated on the heading's existing 10s poll.
|
||||
*
|
||||
* @return array{result: array<string, mixed>, action: ?string, vulnerabilities_url: string, deployment_url: string}|null
|
||||
*/
|
||||
#[Computed]
|
||||
public function imageScanGate(): ?array
|
||||
{
|
||||
// The gate never runs without both. Scanning off on the server still shows: the gate reports it.
|
||||
if (! ImageScanPolicyResolver::isAllowedFor($this->application)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::query()
|
||||
->where('application_id', $this->application->id)
|
||||
->where('pull_request_id', 0)
|
||||
->orderByDesc('id')
|
||||
->first(['id', 'deployment_uuid', 'status', 'scan_gate_result']);
|
||||
|
||||
if (! $deployment || (! $deployment->isImageScanBlocked() && ! $deployment->isImageScanSkipped())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'environment_uuid' => $this->parameters['environment_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
];
|
||||
|
||||
return [
|
||||
'result' => $deployment->scan_gate_result,
|
||||
'action' => $deployment->isImageScanBlocked() ? SecurityCard::gateAction($this->application, $deployment)['sentence'] ?? null : null,
|
||||
'vulnerabilities_url' => route('project.application.vulnerabilities', $parameters),
|
||||
'deployment_url' => route('project.application.deployment.show', [...$parameters, 'deployment_uuid' => $deployment->deployment_uuid]),
|
||||
];
|
||||
}
|
||||
|
||||
public function force_deploy_without_cache()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -42,6 +42,24 @@ class Source extends Component
|
||||
#[Locked]
|
||||
public $sources;
|
||||
|
||||
#[Validate(['nullable', 'string', 'max:65535'])]
|
||||
public ?string $gitSshKnownHosts = null;
|
||||
|
||||
/**
|
||||
* The saved host keys when the form was loaded. Save writes the host keys only when the user changed
|
||||
* them, so it does not remove keys that a deployment saved after the page opened.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?string $loadedGitSshKnownHosts = null;
|
||||
|
||||
/**
|
||||
* Host keys that the Git host sends now, which the user can accept or decline.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
#[Locked]
|
||||
public array $fetchedHostKeys = [];
|
||||
|
||||
#[Validate(['nullable', 'string', 'max:255', 'regex:/^[^:\r\n]*$/'])]
|
||||
public ?string $gitHttpsUsername = null;
|
||||
|
||||
@@ -84,12 +102,16 @@ class Source extends Component
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->application->fill([
|
||||
$attributes = [
|
||||
'git_repository' => $this->gitRepository,
|
||||
'git_branch' => $this->gitBranch,
|
||||
'git_commit_sha' => $this->gitCommitSha,
|
||||
'private_key_id' => $this->privateKeyId,
|
||||
]);
|
||||
];
|
||||
if (gitKnownHostsLines($this->gitSshKnownHosts) !== gitKnownHostsLines($this->loadedGitSshKnownHosts)) {
|
||||
$attributes['git_ssh_known_hosts'] = implode("\n", gitKnownHostsLines($this->gitSshKnownHosts)) ?: null;
|
||||
}
|
||||
$this->application->fill($attributes);
|
||||
if (filled($this->gitHttpsPassword)) {
|
||||
$this->application->git_https_username = $this->gitHttpsUsername;
|
||||
$this->application->git_https_password = $this->gitHttpsPassword;
|
||||
@@ -109,9 +131,135 @@ class Source extends Component
|
||||
$this->gitHttpsUsername = $this->application->git_https_username;
|
||||
$this->gitHttpsPassword = null;
|
||||
$this->hasGitHttpsPassword = filled($this->application->git_https_password);
|
||||
$this->syncKnownHostsFromModel();
|
||||
}
|
||||
}
|
||||
|
||||
private function syncKnownHostsFromModel(): void
|
||||
{
|
||||
$this->gitSshKnownHosts = $this->application->git_ssh_known_hosts;
|
||||
$this->loadedGitSshKnownHosts = $this->application->git_ssh_known_hosts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SHA256 fingerprints of the saved host keys, in the format that `ssh-keygen -lf` shows.
|
||||
* The id identifies the line, because two hosts can have the same key.
|
||||
*
|
||||
* @return list<array{id: string, host: string, type: string, fingerprint: string}>
|
||||
*/
|
||||
public function knownHostFingerprints(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (string $line) => ['id' => sha1($line), 'published' => false, ...gitKnownHostFingerprint($line)],
|
||||
gitKnownHostsLines($this->application->git_ssh_known_hosts),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys that deployments use for a repository on GitHub, GitLab.com, or Bitbucket Cloud (gitProviderHostKeys()).
|
||||
*/
|
||||
public function publishedHostKeyFingerprints(): array
|
||||
{
|
||||
$host = strtolower($this->application->gitSshHost()['host'] ?? '');
|
||||
if (! in_array($host, gitPinnedHosts(), true)) {
|
||||
return [];
|
||||
}
|
||||
$lines = array_filter(gitProviderHostKeys(), fn (string $line) => in_array($host, gitKnownHostsLineHosts($line), true));
|
||||
|
||||
return array_map(
|
||||
fn (string $line) => ['id' => 'published-'.sha1($line), 'published' => true, ...gitKnownHostFingerprint($line), 'host' => $host],
|
||||
array_values($lines),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the saved host key line with this id. The next deployment saves the key that the host sends then.
|
||||
*/
|
||||
public function removeHostKey(string $id): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$lines = array_filter(
|
||||
gitKnownHostsLines($this->application->git_ssh_known_hosts),
|
||||
fn (string $line) => sha1($line) !== $id,
|
||||
);
|
||||
$this->application->update(['git_ssh_known_hosts' => implode("\n", $lines) ?: null]);
|
||||
$this->syncKnownHostsFromModel();
|
||||
$this->dispatch('success', 'Host key removed.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{host: string, type: string, fingerprint: string}>
|
||||
*/
|
||||
public function fetchedHostKeyFingerprints(): array
|
||||
{
|
||||
return array_map(gitKnownHostFingerprint(...), $this->fetchedHostKeys);
|
||||
}
|
||||
|
||||
public function fetchHostKeys(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->fetchedHostKeys = [];
|
||||
$sshHost = $this->application->gitSshHost();
|
||||
$hosts = $this->application->gitSshHostsToScan();
|
||||
if ($sshHost === null && $hosts === []) {
|
||||
$this->dispatch('error', 'The repository does not use SSH.');
|
||||
|
||||
return;
|
||||
}
|
||||
$providerHosts = $sshHost === null ? null : gitProviderHostKeySource($sshHost['host']);
|
||||
if ($providerHosts !== null) {
|
||||
$count = count(fetchGitProviderHostKeys($providerHosts));
|
||||
$this->dispatch('success', "Deployments now use the {$count} host keys that {$sshHost['host']} publishes.");
|
||||
}
|
||||
if ($hosts === []) {
|
||||
return;
|
||||
}
|
||||
$fetchedHostKeys = $this->application->fetchGitSshHostKeys();
|
||||
// A gitlab.com key that is the same as a built-in key needs no accept.
|
||||
$builtInHostKeys = collect(gitProviderHostKeys())->flatMap(function (string $line) {
|
||||
[$hosts, $key] = explode(' ', $line, 2);
|
||||
|
||||
return array_map(fn (string $host) => "{$host} {$key}", explode(',', $hosts));
|
||||
})->all();
|
||||
$this->fetchedHostKeys = array_values(array_diff($fetchedHostKeys, $builtInHostKeys));
|
||||
if ($fetchedHostKeys === []) {
|
||||
$names = implode(', ', array_map(fn (array $host) => "{$host['host']} (port {$host['port']})", $hosts));
|
||||
$this->dispatch('error', "Could not read the host keys of {$names}. To add a key, click Edit manually and paste it.");
|
||||
} elseif ($this->fetchedHostKeys === []) {
|
||||
$this->dispatch('success', 'The host sends the built-in published keys. Nothing to accept.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the saved keys of the fetched host with the fetched keys.
|
||||
*/
|
||||
public function acceptFetchedHostKeys(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$lines = gitKnownHostsReplaceHostKeys($this->application->git_ssh_known_hosts, $this->fetchedHostKeys);
|
||||
$this->application->update(['git_ssh_known_hosts' => implode("\n", $lines) ?: null]);
|
||||
$this->fetchedHostKeys = [];
|
||||
$this->syncKnownHostsFromModel();
|
||||
$this->dispatch('success', 'Host keys saved. Later deployments accept only these keys.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function declineFetchedHostKeys(): void
|
||||
{
|
||||
$this->fetchedHostKeys = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Other keys and sources of the team. Users without write access on the whole team keep the ones the
|
||||
* application already uses, so they only see those.
|
||||
@@ -184,12 +332,32 @@ class Source extends Component
|
||||
if (str($this->gitCommitSha)->isEmpty()) {
|
||||
$this->gitCommitSha = 'HEAD';
|
||||
}
|
||||
$knownHostsLines = collect(preg_split('/\R/', (string) $this->gitSshKnownHosts))
|
||||
->map(fn (string $line) => preg_replace('/\s+/', ' ', trim($line)))
|
||||
->filter(fn (string $line) => $line !== '');
|
||||
if ($knownHostsLines->contains(fn (string $line) => ! isValidGitKnownHostsLine($line))) {
|
||||
throw ValidationException::withMessages(['gitSshKnownHosts' => 'Each line must be "host key-type key" (for example "[git.example.com]:2222 ssh-ed25519 AAAA..."). Hashed hosts and comments are not supported.']);
|
||||
}
|
||||
if (filled($this->gitHttpsUsername) && blank($this->gitHttpsPassword) && ! $this->hasGitHttpsPassword) {
|
||||
throw ValidationException::withMessages(['gitHttpsPassword' => 'Enter the password or token for this username.']);
|
||||
}
|
||||
$loadedKnownHostsLines = array_map(fn (string $line) => preg_replace('/\s+/', ' ', trim($line)), preg_split('/\R/', (string) $this->loadedGitSshKnownHosts));
|
||||
if ($knownHostsLines->contains(fn (string $line) => gitKnownHostsLineHasHostWithPublishedKeys($line) && ! in_array($line, $loadedKnownHostsLines, true))) {
|
||||
throw ValidationException::withMessages(['gitSshKnownHosts' => 'Coolify always uses the published host keys of '.implode(', ', gitHostsWithPublishedKeys()).'. Remove the lines of these hosts.']);
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Application source updated!');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (ValidationException $e) {
|
||||
if (array_key_exists('gitSshKnownHosts', $e->errors())) {
|
||||
$this->dispatch('known-hosts-invalid');
|
||||
}
|
||||
|
||||
if (array_key_exists('gitSshKnownHosts', $e->errors())) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
handleError($e, $this);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user