Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next

This commit is contained in:
Andras Bacsai
2026-09-14 13:26:19 +02:00
420 changed files with 25514 additions and 2698 deletions
+1
View File
@@ -1,6 +1,7 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
APP_DEBUG=true
APP_MAINTENANCE_DRIVER=file
DB_CONNECTION=testing
+20 -5
View File
@@ -3,12 +3,14 @@
namespace App\Actions\Database;
use App\Models\StandaloneClickhouse;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartClickhouse
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneClickhouse $database;
@@ -16,7 +18,11 @@ class StartClickhouse
public string $configuration_dir;
public function handle(StandaloneClickhouse $database)
private string $resolvedClickhouseUser;
private string $resolvedClickhousePassword;
public function handle(StandaloneClickhouse $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -51,7 +57,7 @@ class StartClickhouse
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
'CMD', 'clickhouse-client', '--user', $this->resolvedClickhouseUser, '--password', $this->resolvedClickhousePassword, '--query', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -109,7 +115,7 @@ class StartClickhouse
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -147,8 +153,17 @@ class StartClickhouse
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user;
$this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'CLICKHOUSE_USER') {
$this->resolvedClickhouseUser = $rawValue;
} elseif ($env->key === 'CLICKHOUSE_PASSWORD') {
$this->resolvedClickhousePassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
+30 -26
View File
@@ -2,6 +2,9 @@
namespace App\Actions\Database;
use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Jobs\DatabaseStartJob;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@@ -12,6 +15,7 @@ use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Spatie\Activitylog\Models\Activity;
class StartDatabase
{
@@ -22,7 +26,7 @@ class StartDatabase
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): Activity|string
{
$server = $database->destination->server;
if (! $server->isFunctional()) {
@@ -33,32 +37,32 @@ class StartDatabase
'last_restart_at' => null,
'last_restart_type' => null,
]);
switch ($database->getMorphClass()) {
case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);
break;
case StandaloneRedis::class:
$activity = StartRedis::run($database);
break;
case StandaloneMongodb::class:
$activity = StartMongodb::run($database);
break;
case StandaloneMysql::class:
$activity = StartMysql::run($database);
break;
case StandaloneMariadb::class:
$activity = StartMariadb::run($database);
break;
case StandaloneKeydb::class:
$activity = StartKeydb::run($database);
break;
case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database);
break;
case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database);
break;
$activity = activity()
->withProperties([
'server_uuid' => $server->uuid,
'type' => ActivityTypes::INLINE->value,
'type_uuid' => $database->uuid,
'status' => ProcessStatus::QUEUED->value,
'team_id' => $server->team_id,
'operation' => 'database-start',
])
->performedOn($database)
->event(ActivityTypes::INLINE->value)
->log('[]');
if ($activity === null) {
return 'Database start could not be queued because activity logging is disabled.';
}
DatabaseStartJob::dispatch(
$database->getMorphClass(),
(int) $database->getKey(),
(int) $database->team()->id,
(int) $activity->getKey(),
auth()->id(),
);
if ($database->is_public && $database->public_port) {
StartDatabaseProxy::dispatch($database);
}
@@ -0,0 +1,199 @@
<?php
namespace App\Actions\Database;
use App\Enums\ProcessStatus;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\SwarmDocker;
use App\Support\DatabaseBackupFileValidator;
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
use App\Support\DatabaseImport\DatabaseImportException;
use App\Support\DatabaseImport\DatabaseImportSource;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Throwable;
class StartDatabaseImport
{
use AsAction;
public const MAX_BYTES = 10 * 1024 * 1024 * 1024;
public const LOCK_SECONDS = 1800;
public function __construct(private readonly DatabaseImportCommandBuilder $commands) {}
public static function lockKey(string $resourceUuid): string
{
return "database-import:{$resourceUuid}";
}
public function handle(Model $resource, DatabaseImportSource $source, int $teamId): Activity
{
if (! $this->commands->supports($resource)) {
throw new DatabaseImportException('Database imports are not supported for this database type.');
}
if (! str($resource->status)->startsWith('running')) {
throw new DatabaseImportException('The database must be running before an import can start.');
}
[$server, $container, $network] = $this->target($resource);
$destination = $resource instanceof ServiceDatabase ? $resource->service?->destination : $resource->destination;
if ($destination instanceof SwarmDocker) {
throw new DatabaseImportException('Database imports are not supported for Swarm servers yet.', 501);
}
if (! $server || ! ValidationPatterns::isValidContainerName($container)) {
throw new DatabaseImportException('The database server or container is invalid.', 400);
}
$lock = Cache::lock(self::lockKey($resource->uuid), self::LOCK_SECONDS);
if (! $lock->get()) {
throw new DatabaseImportException('A database import is already running.', 409);
}
try {
return $this->startImport($resource, $source, $teamId, $server, $container, $network);
} finally {
$lock->release();
}
}
private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network): Activity
{
$active = Activity::query()->where('properties->team_id', $teamId)
->where('properties->type_uuid', $resource->uuid)
->where('properties->operation', 'database_import')
->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value])
->exists();
if ($active) {
throw new DatabaseImportException('A database import is already running.', 409);
}
$operation = (string) Str::uuid();
$containerPath = "/tmp/restore_{$operation}";
$scriptPath = "/tmp/restore_{$operation}.sh";
$commandList = [];
$cleanup = ['container' => $container, 'containerTmpPath' => $containerPath, 'scriptPath' => $scriptPath, 'serverId' => $server->id];
if ($source->type === 'upload') {
$staged = $source->uploadId
? "upload/imports/{$teamId}/{$resource->uuid}/{$source->uploadId}/restore"
: "upload/{$resource->uuid}/restore";
if (! Storage::exists($staged)) {
throw new DatabaseImportException('The completed upload was not found.');
}
$local = Storage::path($staged);
if ($this->commands->databaseType($resource) === 'postgresql' && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($local)) {
Storage::delete($staged);
throw new DatabaseImportException('The uploaded backup contains disallowed PostgreSQL restore directives.');
}
$serverPath = "/tmp/database-import-{$operation}";
instant_scp($local, $serverPath, $server);
$source->uploadId ? Storage::deleteDirectory(dirname($staged)) : Storage::delete($staged);
$commandList[] = 'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}");
$commandList[] = 'rm -f '.escapeshellarg($serverPath);
$cleanup['serverTmpPath'] = $serverPath;
} elseif ($source->type === 'server') {
$this->assertServerPath($source->path);
$size = (int) trim((string) instant_remote_process(['stat -c %s -- '.escapeshellarg($source->path)], $server));
if ($size < 1 || $size > self::MAX_BYTES) {
throw new DatabaseImportException('The backup file is empty or exceeds the 10 GiB limit.');
}
$commandList[] = 'docker cp '.escapeshellarg($source->path).' '.escapeshellarg("{$container}:{$containerPath}");
} else {
$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)) {
throw new DatabaseImportException('S3 storage was not found or has an invalid bucket.');
}
$key = ltrim((string) $source->path, '/');
$this->assertS3Path($key);
$disk = $storage->filesystem();
if (! $disk->exists($key) || $disk->size($key) > self::MAX_BYTES) {
throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
}
$helper = "s3-restore-{$operation}";
$serverPath = "/tmp/s3-restore-{$operation}";
$this->startS3HelperWithEnv($storage, $server, $helper, $network);
$sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}");
$commandList = [
'docker exec '.escapeshellarg($helper).' sh -c '.escapeshellarg('mc alias set s3temp "$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY"'),
'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore',
'docker cp '.escapeshellarg("{$helper}:/tmp/restore").' '.escapeshellarg($serverPath),
'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}"),
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
'rm -f '.escapeshellarg($serverPath),
];
$cleanup += ['containerName' => $helper, 'serverTmpPath' => $serverPath];
}
if ($safety = $this->commands->buildPostgresSafetyCommand($resource, $container, $containerPath)) {
$commandList[] = $safety;
}
$restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll, $source->replaceExisting));
$commandList[] = 'echo '.escapeshellarg($restore).' | base64 -d > '.escapeshellarg($scriptPath);
$commandList[] = 'chmod +x '.escapeshellarg($scriptPath);
$commandList[] = 'docker cp '.escapeshellarg($scriptPath).' '.escapeshellarg("{$container}:{$scriptPath}");
$commandList[] = 'rm -f '.escapeshellarg($scriptPath);
$commandList[] = 'docker exec '.escapeshellarg($container).' sh -c '.escapeshellarg($scriptPath);
$activity = remote_process($commandList, $server, type_uuid: $resource->uuid, model: $resource, callEventOnFinish: 'DatabaseImportFinished', callEventData: $cleanup);
$activity->properties = $activity->properties->merge(['operation' => 'database_import', 'resource_kind' => $resource instanceof ServiceDatabase ? 'service_database' : 'standalone_database', 'operation_uuid' => $operation]);
$activity->save();
return $activity;
}
private function target(Model $resource): array
{
if ($resource instanceof ServiceDatabase) {
return [$resource->service?->server, $resource->name.'-'.$resource->service?->uuid, $resource->service?->destination?->network ?? 'coolify'];
}
return [$resource->destination?->server, $resource->uuid, $resource->destination?->network ?? 'coolify'];
}
private function assertServerPath(?string $path): void
{
if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
throw new DatabaseImportException('The server path is invalid.');
}
}
private function assertS3Path(string $path): void
{
if ($path === '' || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
throw new DatabaseImportException('The S3 path is invalid.');
}
}
private function startS3HelperWithEnv(S3Storage $storage, Server $server, string $helper, string $network): void
{
$image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion());
try {
instant_remote_process([
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
'docker run -d --network '.escapeshellarg($network)
.' --name '.escapeshellarg($helper)
.' -e S3_ENDPOINT='.escapeshellarg((string) $storage->endpoint)
.' -e S3_ACCESS_KEY='.escapeshellarg((string) $storage->key)
.' -e S3_SECRET_KEY='.escapeshellarg((string) $storage->secret)
.' '.$image.' sleep 3600',
], $server);
} catch (Throwable) {
instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false);
throw new DatabaseImportException('Unable to start the S3 restore helper.');
}
}
}
+17 -6
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartDragonfly
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneDragonfly $database;
@@ -20,7 +22,9 @@ class StartDragonfly
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneDragonfly $database)
private string $resolvedRedisPassword;
public function handle(StandaloneDragonfly $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -107,7 +111,7 @@ class StartDragonfly
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -196,12 +200,13 @@ class StartDragonfly
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function buildStartCommand(): string
{
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
$command = "dragonfly --requirepass {$escapedRedisPassword}";
if ($this->database->enable_ssl) {
$sslArgs = [
@@ -251,8 +256,14 @@ class StartDragonfly
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
+18 -7
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartKeydb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneKeydb $database;
@@ -20,7 +22,9 @@ class StartKeydb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneKeydb $database)
private string $resolvedRedisPassword;
public function handle(StandaloneKeydb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -109,7 +113,7 @@ class StartKeydb
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
'CMD', 'keydb-cli', '--pass', $this->resolvedRedisPassword, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -214,7 +218,7 @@ class StartKeydb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -252,8 +256,14 @@ class StartKeydb
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedRedisPassword = (string) $this->database->keydb_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
@@ -280,6 +290,7 @@ class StartKeydb
{
$hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf);
$keydbConfPath = '/etc/keydb/keydb.conf';
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
if ($hasKeydbConf) {
$confContent = $this->database->keydb_conf;
@@ -288,10 +299,10 @@ class StartKeydb
if ($hasRequirePass) {
$command = "keydb-server $keydbConfPath";
} else {
$command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
$command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}";
}
} else {
$command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
$command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes";
}
if ($this->database->enable_ssl) {
+6 -4
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMariadb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMariadb $database;
@@ -20,7 +22,7 @@ class StartMariadb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMariadb $database)
public function handle(StandaloneMariadb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -216,7 +218,7 @@ class StartMariadb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -255,7 +257,7 @@ class StartMariadb
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) {
+27 -7
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMongodb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMongodb $database;
@@ -20,7 +22,13 @@ class StartMongodb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMongodb $database)
private string $resolvedMongoUsername;
private string $resolvedMongoPassword;
private string $resolvedMongoDatabase;
public function handle(StandaloneMongodb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -265,7 +273,7 @@ class StartMongodb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -303,8 +311,20 @@ class StartMongodb
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedMongoUsername = (string) $this->database->mongo_initdb_root_username;
$this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password;
$this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') {
$this->resolvedMongoUsername = $rawValue;
} elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') {
$this->resolvedMongoPassword = $rawValue;
} elseif ($env->key === 'MONGO_INITDB_DATABASE') {
$this->resolvedMongoDatabase = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
@@ -337,9 +357,9 @@ class StartMongodb
private function add_default_database()
{
$dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
$userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
$pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
$dbJson = json_encode($this->resolvedMongoDatabase, JSON_UNESCAPED_SLASHES);
$userJson = json_encode($this->resolvedMongoUsername, JSON_UNESCAPED_SLASHES);
$pwdJson = json_encode($this->resolvedMongoPassword, JSON_UNESCAPED_SLASHES);
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
+15 -5
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMysql
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMysql $database;
@@ -20,7 +22,9 @@ class StartMysql
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMysql $database)
private string $resolvedMysqlRootPassword;
public function handle(StandaloneMysql $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -104,7 +108,7 @@ class StartMysql
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->resolvedMysqlRootPassword}",
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -218,7 +222,7 @@ class StartMysql
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -256,8 +260,14 @@ class StartMysql
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'MYSQL_ROOT_PASSWORD') {
$this->resolvedMysqlRootPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
+20 -5
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartPostgresql
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandalonePostgresql $database;
@@ -22,7 +24,11 @@ class StartPostgresql
private ?SslCertificate $ssl_certificate = null;
public function handle(StandalonePostgresql $database)
private string $resolvedPostgresUser;
private string $resolvedPostgresDatabase;
public function handle(StandalonePostgresql $database, ?Activity $activity = null)
{
$this->database = $database;
$container_name = $this->database->uuid;
@@ -111,7 +117,7 @@ class StartPostgresql
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
'CMD', 'psql', '-U', $this->resolvedPostgresUser, '-d', $this->resolvedPostgresDatabase, '-c', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -227,7 +233,7 @@ class StartPostgresql
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -265,8 +271,17 @@ class StartPostgresql
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedPostgresUser = (string) $this->database->postgres_user;
$this->resolvedPostgresDatabase = (string) $this->database->postgres_db;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'POSTGRES_USER') {
$this->resolvedPostgresUser = $rawValue;
} elseif ($env->key === 'POSTGRES_DB') {
$this->resolvedPostgresDatabase = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
+35 -11
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartRedis
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneRedis $database;
@@ -20,7 +22,11 @@ class StartRedis
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneRedis $database)
private ?string $resolvedRedisPassword = null;
private ?string $resolvedRedisUsername = null;
public function handle(StandaloneRedis $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -209,7 +215,7 @@ class StartRedis
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -249,23 +255,40 @@ class StartRedis
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
if ($env->is_shared) {
$environment_variables->push("$env->key=$env->real_value");
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
if ($env->key === 'REDIS_PASSWORD') {
$this->database->update(['redis_password' => $env->real_value]);
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
if (! $usesSecretManager) {
$this->database->update(['redis_password' => $this->resolvedRedisPassword]);
}
}
if ($env->key === 'REDIS_USERNAME') {
$this->database->update(['redis_username' => $env->real_value]);
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
if (! $usesSecretManager) {
$this->database->update(['redis_username' => $this->resolvedRedisUsername]);
}
}
} else {
if ($env->key === 'REDIS_PASSWORD') {
if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) {
$env->update(['value' => $this->database->redis_password]);
} elseif ($env->key === 'REDIS_USERNAME') {
} elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) {
$env->update(['value' => $this->database->redis_username]);
}
$environment_variables->push("$env->key=$env->real_value");
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
} elseif ($env->key === 'REDIS_USERNAME') {
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
}
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
}
}
@@ -276,6 +299,7 @@ class StartRedis
private function buildStartCommand(): string
{
$redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password;
$hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf);
$redisConfPath = '/usr/local/etc/redis/redis.conf';
@@ -286,10 +310,10 @@ class StartRedis
if ($hasRequirePass) {
$command = "redis-server $redisConfPath";
} else {
$command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}";
$command = "redis-server $redisConfPath --requirepass {$redisPassword}";
}
} else {
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
$command = "redis-server --requirepass {$redisPassword} --appendonly yes";
}
if ($this->database->enable_ssl) {
+1 -1
View File
@@ -30,7 +30,7 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
if (! $settings->isPasswordRegistrationAllowed()) {
abort(403);
}
+39 -1
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
class CheckUpdates
@@ -106,6 +107,15 @@ class CheckUpdates
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
case 'apk':
instant_remote_process(['apk update -q'], $server);
$output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server);
$out = $this->parseApkOutput($output);
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
default:
return [
@@ -266,11 +276,39 @@ class CheckUpdates
// Include unparsed lines in the result for debugging if any exist
if (! empty($unparsedLines)) {
$result['unparsed_lines'] = $unparsedLines;
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
Log::debug('Pacman output contained unparsed lines', [
'unparsed_lines' => $unparsedLines,
]);
}
return $result;
}
private function parseApkOutput(string $output): array
{
$updates = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
// Skip empty lines
if (empty($line)) {
continue;
}
// Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) {
$updates[] = [
'package' => $matches[1],
'new_version' => $matches[2],
'architecture' => $matches[3],
'current_version' => $matches[4],
];
}
}
return [
'total_updates' => count($updates),
'updates' => $updates,
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Server;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Jobs\RestartProxyJob;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class ConfigureTrafficAnalytics
{
use AsAction;
public function handle(Server $server, bool $enable): void
{
$sentinelWasEnabled = (bool) $server->settings->is_sentinel_enabled;
$server->settings->is_traffic_analytics_enabled = $enable;
$server->settings->save();
$server->refresh();
// Regenerate proxy config so the (Traefik) access-log flags / (Caddy) log labels take effect.
GetProxyConfiguration::run($server, forceRegenerate: true);
RestartProxyJob::dispatch($server);
// Recreate Sentinel so it picks up (enabling) or drops (disabling) the traffic env + proxy-log mount.
// Enabling analytics needs Sentinel running; when disabling, only restart if Sentinel was already
// enabled so we never turn Sentinel on as a side effect of disabling analytics.
if ($enable || $sentinelWasEnabled) {
StartSentinel::run($server, restart: true);
}
}
}
+25 -2
View File
@@ -79,6 +79,8 @@ class InstallDocker
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
} elseif ($supported_os_type->contains('arch')) {
$command = $command->merge([$this->getArchDockerInstallCommand()]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge([$this->getAlpineDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
@@ -93,9 +95,8 @@ class InstallDocker
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json',
"echo 'Restarting Docker Engine...'",
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
]);
$command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine')));
if ($server->isSwarm()) {
$command = $command->merge([
'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true',
@@ -154,6 +155,28 @@ class InstallDocker
'systemctl start docker.service';
}
private function getAlpineDockerInstallCommand(): string
{
return 'apk update && '.
'apk add docker docker-cli-buildx docker-cli-compose && '.
'mkdir -p /etc/docker';
}
private function getDockerServiceCommands(bool $usesOpenRc): array
{
if ($usesOpenRc) {
return [
'rc-update add docker default',
'rc-service docker restart',
];
}
return [
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
];
}
private function getGenericDockerInstallCommand(): string
{
return 'curl -fsSL https://get.docker.com | sh';
@@ -53,6 +53,8 @@ class InstallPrerequisites
"echo 'Installing Prerequisites for Arch Linux...'",
'pacman -Syu --noconfirm --needed curl wget git jq',
]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge($this->getAlpinePrerequisiteCommands());
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
@@ -61,4 +63,18 @@ class InstallPrerequisites
return remote_process($command, $server);
}
private function getAlpinePrerequisiteCommands(): array
{
return [
"echo 'Installing Prerequisites for Alpine Linux...'",
"sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true",
'apk update',
'command -v bash >/dev/null || apk add bash',
'command -v curl >/dev/null || apk add curl',
'command -v wget >/dev/null || apk add wget',
'command -v git >/dev/null || apk add git',
'command -v jq >/dev/null || apk add jq',
];
}
}
+40 -1
View File
@@ -10,6 +10,40 @@ class StartSentinel
{
use AsAction;
public static function trafficLogDirectory(Server $server): string
{
return isDev()
? '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'
: rtrim($server->proxyPath(), '/');
}
public static function sentinelTrafficEnvironment(Server $server): array
{
if (! $server->isTrafficAnalyticsEnabled()) {
return [];
}
$logPath = self::trafficLogDirectory($server).'/access.log';
$settings = $server->settings;
$env = [
'TRAFFIC_ENABLED' => 'true',
'TRAFFIC_PROXY_TYPE' => 'auto',
'TRAFFIC_ACCESS_LOG_PATH' => $logPath,
'TRAFFIC_TOPN' => (string) ($settings->traffic_topn ?: 50),
'TRAFFIC_SAMPLE_THRESHOLD' => (string) ($settings->traffic_sample_threshold ?? 0),
'TRAFFIC_RETENTION_1H_DAYS' => (string) ($settings->traffic_retention_1h_days ?: 30),
'TRAFFIC_RETENTION_1D_DAYS' => (string) ($settings->traffic_retention_1d_days ?: 395),
'GEOIP_ENABLED' => $settings->is_geoip_enabled ? 'true' : 'false',
'GEOIP_REFRESH_DAYS' => (string) ($settings->geoip_refresh_days ?: 30),
];
$license = data_get($settings, 'geoip_maxmind_license_key');
if ($settings->is_geoip_enabled && filled($license)) {
$env['GEOIP_MAXMIND_LICENSE_KEY'] = $license;
}
return $env;
}
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
{
if ($server->isSwarm() || $server->isBuildServer()) {
@@ -36,6 +70,7 @@ class StartSentinel
'COLLECTOR_REFRESH_RATE_SECONDS' => $refreshRate,
'COLLECTOR_RETENTION_PERIOD_DAYS' => $metricsHistory,
];
$environments = array_merge($environments, self::sentinelTrafficEnvironment($server));
$labels = [
'coolify.managed' => 'true',
];
@@ -48,7 +83,11 @@ class StartSentinel
}
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --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";
$trafficLogDirectory = self::trafficLogDirectory($server);
$trafficMount = $server->isTrafficAnalyticsEnabled()
? '-v '.escapeshellarg("{$trafficLogDirectory}:{$trafficLogDirectory}:ro").' '
: '';
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--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";
instant_remote_process([
'docker rm -f coolify-sentinel || true',
+4
View File
@@ -58,6 +58,10 @@ class UpdatePackage
$commandAll = 'pacman -Syu --noconfirm';
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
break;
case 'apk':
$commandAll = 'apk update && apk upgrade';
$commandInstall = 'apk upgrade '.$sanitizedPackage;
break;
default:
return [
'error' => 'OS not supported',
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcDiscoveryException extends OidcException {}
@@ -0,0 +1,7 @@
<?php
namespace App\Auth\Oidc\Exceptions;
use RuntimeException;
class OidcException extends RuntimeException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcJwksException extends OidcException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcSigningKeyNotFoundException extends OidcTokenException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcTokenException extends OidcException {}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Auth\Oidc;
use App\Models\OauthSetting;
final readonly class OidcConfig
{
/**
* @param array<int, string> $scopes
*/
public function __construct(
public string $issuerUrl,
public string $clientId,
public string $clientSecret,
public string $redirectUri,
public array $scopes = ['openid', 'email', 'profile'],
public bool $usePkce = true,
public int $clockSkewSeconds = 60,
) {}
public static function fromOauthSetting(OauthSetting $setting): self
{
return new self(
issuerUrl: rtrim((string) $setting->base_url, '/'),
clientId: (string) $setting->client_id,
clientSecret: (string) $setting->client_secret,
redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'),
scopes: $setting->scopeList(),
usePkce: $setting->use_pkce ?? true,
clockSkewSeconds: $setting->clock_skew_seconds ?? 60,
);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
final readonly class OidcDiscoveryDocument
{
/**
* @param array<int, string> $supportedScopes
* @param array<int, string> $supportedClaims
* @param array<int, string> $idTokenSigningAlgValuesSupported
*/
public function __construct(
public string $issuer,
public string $authorizationEndpoint,
public string $tokenEndpoint,
public string $userinfoEndpoint,
public string $jwksUri,
public ?string $endSessionEndpoint = null,
public array $supportedScopes = [],
public array $supportedClaims = [],
public array $idTokenSigningAlgValuesSupported = [],
) {}
/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) {
if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') {
throw new OidcDiscoveryException("Discovery document is missing required field: {$field}");
}
}
return new self(
issuer: $payload['issuer'],
authorizationEndpoint: $payload['authorization_endpoint'],
tokenEndpoint: $payload['token_endpoint'],
userinfoEndpoint: $payload['userinfo_endpoint'],
jwksUri: $payload['jwks_uri'],
endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null,
supportedScopes: self::stringList($payload['scopes_supported'] ?? []),
supportedClaims: self::stringList($payload['claims_supported'] ?? []),
idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []),
);
}
/**
* @return array<int, string>
*/
private static function stringList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_map('strval', $value));
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
use App\Auth\Oidc\Exceptions\OidcJwksException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;
class OidcDiscoveryService
{
public function discover(string $issuerUrl): OidcDiscoveryDocument
{
$this->assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.'));
$issuerUrl = rtrim($issuerUrl, '/');
$cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl);
return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument {
$url = $issuerUrl.'/.well-known/openid-configuration';
try {
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url);
} catch (Throwable $e) {
throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e);
}
if ($response->failed()) {
throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}");
}
$json = $response->json();
if (! is_array($json) || $json === []) {
throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.');
}
$discovery = OidcDiscoveryDocument::fromArray($json);
if (rtrim($discovery->issuer, '/') !== $issuerUrl) {
throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.');
}
return $discovery;
});
}
/**
* Fetch the JWKS for the given URI.
*
* When $forceRefresh is true the cached document is bypassed so freshly
* rotated signing keys become visible immediately. A short cooldown still
* prevents a flood of upstream requests if many logins miss the same kid.
*
* @return array<string, mixed>
*/
public function jwks(string $jwksUri, bool $forceRefresh = false): array
{
$this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.'));
$cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri);
if ($forceRefresh) {
$cooldownKey = $cacheKey.':refresh';
if (Cache::add($cooldownKey, true, 60)) {
Cache::forget($cacheKey);
}
}
return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array {
try {
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri);
} catch (Throwable $e) {
throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e);
}
if ($response->failed()) {
throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}");
}
$json = $response->json();
if (! is_array($json) || ! is_array($json['keys'] ?? null)) {
throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'.");
}
return $json;
});
}
private function assertHttpsUrl(string $url, Throwable $exception): void
{
$parts = parse_url($url);
if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') {
throw $exception;
}
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
use App\Auth\Oidc\Exceptions\OidcTokenException;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Throwable;
class OidcTokenValidator
{
/**
* Algorithms we accept for id_token signatures. RS256 only — this is the
* OIDC baseline and a strict allowlist prevents algorithm-confusion and
* "none" attacks.
*/
private const ALLOWED_ALGORITHM = 'RS256';
/**
* @param array<string, mixed> $jwks
* @return array<string, mixed>
*/
public function validate(
string $idToken,
OidcDiscoveryDocument $discovery,
array $jwks,
string $clientId,
?string $expectedNonce = null,
int $clockSkewSeconds = 60,
): array {
$kid = $this->extractKid($idToken);
try {
$keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM);
} catch (Throwable $e) {
throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e);
}
// Surface an unknown signing key distinctly so the caller can refresh
// the JWKS once (key rotation) before giving up.
if (! array_key_exists($kid, $keys)) {
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
}
$previousLeeway = JWT::$leeway;
JWT::$leeway = $clockSkewSeconds;
try {
// Validates signature, header alg against the key alg (RS256),
// exp, nbf and iat. Throws on any failure.
$claims = (array) JWT::decode($idToken, $keys);
} catch (OidcTokenException $e) {
throw $e;
} catch (Throwable $e) {
throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e);
} finally {
JWT::$leeway = $previousLeeway;
}
$this->assertExpiry($claims);
$this->assertIssuer($claims, $discovery->issuer);
$this->assertAudience($claims, $clientId);
$this->assertNonce($claims, $expectedNonce);
$this->assertSubject($claims);
return $claims;
}
/**
* Drop JWKS entries explicitly marked for anything other than signing
* (e.g. "use":"enc") so they can never verify an id_token signature.
* firebase/php-jwt does not honour the "use" parameter on its own.
*
* @param array<string, mixed> $jwks
* @return array<string, mixed>
*/
private function signingKeysOnly(array $jwks): array
{
$keys = array_values(array_filter(
$jwks['keys'] ?? [],
fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'),
));
return ['keys' => $keys];
}
/**
* Decode just the JWT header to read the kid before signature
* verification, so an unknown key can be reported as a rotation miss.
*/
private function extractKid(string $idToken): string
{
$segments = explode('.', $idToken);
if (count($segments) !== 3) {
throw new OidcTokenException('Malformed id_token.');
}
$header = json_decode($this->base64UrlDecode($segments[0]), true);
if (! is_array($header)) {
throw new OidcTokenException('id_token header contains invalid JSON.');
}
if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) {
throw new OidcTokenException('id_token uses a disallowed algorithm.');
}
$kid = $header['kid'] ?? null;
if (! is_string($kid) || $kid === '') {
throw new OidcTokenException('id_token header is missing kid.');
}
return $kid;
}
private function base64UrlDecode(string $value): string
{
$remainder = strlen($value) % 4;
if ($remainder !== 0) {
$value .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
if ($decoded === false) {
throw new OidcTokenException('Invalid base64url value in id_token header.');
}
return $decoded;
}
/**
* @param array<string, mixed> $claims
*/
private function assertExpiry(array $claims): void
{
// Firebase enforces the exp window when present; OIDC requires it to exist.
if (! is_numeric($claims['exp'] ?? null)) {
throw new OidcTokenException('id_token is missing the exp claim.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertSubject(array $claims): void
{
$subject = $claims['sub'] ?? null;
if (! is_string($subject) || $subject === '') {
throw new OidcTokenException('id_token subject is missing or invalid.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertIssuer(array $claims, string $expectedIssuer): void
{
if (($claims['iss'] ?? null) !== $expectedIssuer) {
throw new OidcTokenException('id_token issuer does not match discovery issuer.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertAudience(array $claims, string $clientId): void
{
$audience = $claims['aud'] ?? null;
if (is_string($audience)) {
$audience = [$audience];
}
if (! is_array($audience) || ! in_array($clientId, $audience, true)) {
throw new OidcTokenException('id_token audience does not include configured client id.');
}
if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) {
throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.');
}
if (isset($claims['azp']) && $claims['azp'] !== $clientId) {
throw new OidcTokenException('id_token azp does not match configured client id.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertNonce(array $claims, ?string $expectedNonce): void
{
if ($expectedNonce === null) {
return;
}
if (($claims['nonce'] ?? null) !== $expectedNonce) {
throw new OidcTokenException('id_token nonce does not match.');
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Auth\Oidc;
use Laravel\Socialite\Two\User as SocialiteUser;
class OidcUser extends SocialiteUser
{
public ?string $issuer = null;
public ?string $subject = null;
public bool $emailVerified = false;
/**
* @var array<string, mixed>
*/
public array $idTokenClaims = [];
/**
* @param array<string, mixed> $claims
*/
public function setIdTokenClaims(array $claims): self
{
$this->idTokenClaims = $claims;
$this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null;
$this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null;
$this->emailVerified = ($claims['email_verified'] ?? false) === true;
return $this;
}
}
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace App\Auth\Oidc\Socialite;
use App\Auth\Oidc\Exceptions\OidcException;
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
use App\Auth\Oidc\OidcConfig;
use App\Auth\Oidc\OidcDiscoveryDocument;
use App\Auth\Oidc\OidcDiscoveryService;
use App\Auth\Oidc\OidcTokenValidator;
use App\Auth\Oidc\OidcUser;
use GuzzleHttp\RequestOptions;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\InvalidStateException;
use Laravel\Socialite\Two\ProviderInterface;
class OidcProvider extends AbstractProvider implements ProviderInterface
{
private const int OIDC_FLOW_TTL_MINUTES = 10;
/**
* @var array<int, string>
*/
protected $scopes = ['openid', 'email', 'profile'];
protected $scopeSeparator = ' ';
protected ?OidcConfig $oidcConfig = null;
protected ?OidcDiscoveryDocument $discovery = null;
public function __construct(
Request $request,
protected OidcDiscoveryService $discoveryService,
protected OidcTokenValidator $tokenValidator,
string $clientId,
string $clientSecret,
string $redirectUrl,
) {
parent::__construct($request, $clientId, $clientSecret, $redirectUrl);
}
public function setConfig(OidcConfig $config): self
{
$this->oidcConfig = $config;
$this->clientId = $config->clientId;
$this->clientSecret = $config->clientSecret;
$this->redirectUrl = $config->redirectUri;
$this->scopes = $config->scopes;
$this->discovery = null;
return $this;
}
public function getConfig(): OidcConfig
{
if ($this->oidcConfig === null) {
throw new OidcException('OIDC provider config is not set.');
}
return $this->oidcConfig;
}
protected function getAuthUrl($state): string
{
$config = $this->getConfig();
$nonce = Str::random(40);
$this->putOidcFlowValue($this->nonceSessionKey($state), $nonce);
$extra = ['nonce' => $nonce];
if ($config->usePkce) {
$verifier = $this->generateCodeVerifier();
$this->putOidcFlowValue($this->verifierSessionKey($state), $verifier);
$extra['code_challenge'] = $this->codeChallenge($verifier);
$extra['code_challenge_method'] = 'S256';
}
return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state)
.'&'.http_build_query($extra, '', '&', $this->encodingType);
}
protected function getTokenUrl(): string
{
return $this->resolveDiscovery()->tokenEndpoint;
}
/**
* @return array<string, mixed>
*/
protected function getUserByToken($token): array
{
$response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [
RequestOptions::HEADERS => [
'Accept' => 'application/json',
'Authorization' => 'Bearer '.$token,
],
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 10,
]);
$decoded = json_decode((string) $response->getBody(), true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string, mixed> $user
*/
protected function mapUserToObject(array $user)
{
return (new OidcUser)->setRaw($user)->map([
'id' => $user['sub'] ?? null,
'nickname' => $user['preferred_username'] ?? null,
'name' => $this->resolveName($user),
'email' => $user['email'] ?? null,
'avatar' => $user['picture'] ?? null,
]);
}
public function user()
{
if ($this->user) {
return $this->user;
}
if ($this->hasInvalidState()) {
throw new InvalidStateException;
}
$tokenResponse = $this->getAccessTokenResponse($this->getCode());
$accessToken = Arr::get($tokenResponse, 'access_token');
$idToken = Arr::get($tokenResponse, 'id_token');
if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') {
throw new OidcException('OIDC token endpoint did not return required tokens.');
}
$discovery = $this->resolveDiscovery();
$config = $this->getConfig();
$expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state')));
if ($expectedNonce === null) {
throw new OidcException('OIDC login session expired. Please try again.');
}
$claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce);
$userinfo = $this->getUserByToken($accessToken);
// OIDC core §5.3.2: the userinfo sub MUST match the id_token sub.
// Reject the response rather than trust unsigned userinfo claims.
$userinfoSub = $userinfo['sub'] ?? null;
if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) {
throw new OidcException('OIDC userinfo subject does not match the id_token subject.');
}
$merged = array_merge($userinfo, $claims);
/** @var OidcUser $user */
$user = $this->mapUserToObject($merged);
$user->setIdTokenClaims($claims)
->setToken($accessToken)
->setRefreshToken(Arr::get($tokenResponse, 'refresh_token'))
->setExpiresIn(Arr::get($tokenResponse, 'expires_in'));
return $this->user = $user;
}
/**
* Validate the id_token, retrying once against a freshly fetched JWKS when
* the signing key is unknown. This keeps logins working immediately after
* the IdP rotates keys instead of failing until the JWKS cache expires.
*
* @return array<string, mixed>
*/
protected function validateIdToken(
string $idToken,
OidcDiscoveryDocument $discovery,
OidcConfig $config,
?string $expectedNonce,
): array {
foreach ([false, true] as $forceRefresh) {
try {
return $this->tokenValidator->validate(
idToken: $idToken,
discovery: $discovery,
jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh),
clientId: $config->clientId,
expectedNonce: $expectedNonce,
clockSkewSeconds: $config->clockSkewSeconds,
);
} catch (OidcSigningKeyNotFoundException $e) {
if ($forceRefresh) {
throw $e;
}
}
}
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
}
/**
* @return array<string, mixed>
*/
public function getAccessTokenResponse($code)
{
$fields = $this->getTokenFields($code);
if ($this->getConfig()->usePkce) {
$verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state')));
if ($verifier === null) {
throw new OidcException('OIDC login session expired. Please try again.');
}
$fields['code_verifier'] = $verifier;
}
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
RequestOptions::HEADERS => ['Accept' => 'application/json'],
RequestOptions::FORM_PARAMS => $fields,
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 10,
]);
$decoded = json_decode((string) $response->getBody(), true);
return is_array($decoded) ? $decoded : [];
}
protected function resolveDiscovery(): OidcDiscoveryDocument
{
return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl);
}
protected function generateCodeVerifier(): string
{
return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
}
protected function codeChallenge(string $verifier): string
{
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
}
/**
* @param array<string, mixed> $user
*/
protected function resolveName(array $user): ?string
{
if (is_string($user['name'] ?? null) && $user['name'] !== '') {
return $user['name'];
}
$name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? '')));
return $name === '' ? null : $name;
}
protected function putOidcFlowValue(string $key, string $value): void
{
$this->request->session()->put($key, [
'value' => $value,
'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp,
]);
}
protected function pullOidcFlowValue(string $key): ?string
{
$entry = $this->request->session()->pull($key);
if (! is_array($entry)) {
return null;
}
$value = $entry['value'] ?? null;
$expiresAt = $entry['expires_at'] ?? null;
if (! is_string($value) || $value === '' || ! is_int($expiresAt)) {
return null;
}
if ($expiresAt < now()->timestamp) {
return null;
}
return $value;
}
protected function nonceSessionKey(string $state): string
{
return "oidc.nonce.{$state}";
}
protected function verifierSessionKey(string $state): string
{
return "oidc.code_verifier.{$state}";
}
}
+7
View File
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Models\AuditEvent;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -49,6 +50,12 @@ class CleanupDatabase extends Command
$activity_log->delete();
}
$count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count();
echo "Delete $count entries from audit_events.\n";
if ($this->option('yes')) {
AuditEvent::pruneExpired();
}
// Cleanup application_deployment_queues table
$application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10);
$count = $application_deployment_queues->count();
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Data\Traffic;
use Spatie\LaravelData\Data;
class TrafficBreakdownData extends Data
{
public function __construct(
public string $value,
public int $requests,
public int $bytesOut,
) {}
public static function fromSentinel(array $row): self
{
return new self(
value: (string) data_get($row, 'value', ''),
requests: (int) data_get($row, 'requests', 0),
bytesOut: (int) data_get($row, 'bytes_out', 0),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Data\Traffic;
use Spatie\LaravelData\Data;
class TrafficOverviewData extends Data
{
public function __construct(
public int $requests,
public int $bytesIn,
public int $bytesOut,
public int $s2xx,
public int $s3xx,
public int $s4xx,
public int $s5xx,
public float $latencyP50,
public float $latencyP95,
public float $latencyP99,
public int $uniqueVisitors,
) {}
public static function fromSentinel(array $json): self
{
return new self(
requests: (int) data_get($json, 'requests', 0),
bytesIn: (int) data_get($json, 'bytes_in', 0),
bytesOut: (int) data_get($json, 'bytes_out', 0),
s2xx: (int) data_get($json, 'status.s2xx', 0),
s3xx: (int) data_get($json, 'status.s3xx', 0),
s4xx: (int) data_get($json, 'status.s4xx', 0),
s5xx: (int) data_get($json, 'status.s5xx', 0),
latencyP50: (float) data_get($json, 'latency.p50', 0.0),
latencyP95: (float) data_get($json, 'latency.p95', 0.0),
latencyP99: (float) data_get($json, 'latency.p99', 0.0),
uniqueVisitors: (int) data_get($json, 'unique_visitors', 0),
);
}
public static function zero(): self
{
return new self(0, 0, 0, 0, 0, 0, 0, 0.0, 0.0, 0.0, 0);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Data\Traffic;
use Spatie\LaravelData\Data;
class TrafficPathData extends Data
{
public function __construct(
public string $path,
public int $requests,
public int $bytesOut,
public int $s4xx,
public int $s5xx,
public float $p50,
public float $p95,
// Owning app key (Sentinel returns this per path row so the UI can show the
// domain). Empty on older Sentinel builds that predate the field.
public string $app = '',
) {}
public static function fromSentinel(array $row): self
{
return new self(
path: (string) data_get($row, 'path', ''),
requests: (int) data_get($row, 'requests', 0),
bytesOut: (int) data_get($row, 'bytes_out', 0),
s4xx: (int) data_get($row, 's4xx', 0),
s5xx: (int) data_get($row, 's5xx', 0),
p50: (float) data_get($row, 'p50', 0.0),
p95: (float) data_get($row, 'p95', 0.0),
app: (string) data_get($row, 'app', ''),
);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Data\Traffic;
use Spatie\LaravelData\Data;
class TrafficSeriesBucketData extends Data
{
public function __construct(
// Unix-millis start of the bucket (hour- or day-aligned by the request's range).
public int $bucket,
public int $s2xx,
public int $s3xx,
public int $s4xx,
public int $s5xx,
// Per-bucket aggregates (added to Sentinel's series alongside the status classes)
// so every KPI card can draw a real sparkline. Older Sentinel omits these; they
// default to 0 and requests falls back to the status-class sum.
public int $requests = 0,
public int $bytesIn = 0,
public int $bytesOut = 0,
public int $uniqueVisitors = 0,
public float $p95 = 0.0,
) {}
public static function fromSentinel(array $row): self
{
$s2xx = (int) data_get($row, 's2xx', 0);
$s3xx = (int) data_get($row, 's3xx', 0);
$s4xx = (int) data_get($row, 's4xx', 0);
$s5xx = (int) data_get($row, 's5xx', 0);
return new self(
bucket: (int) data_get($row, 'bucket', 0),
s2xx: $s2xx,
s3xx: $s3xx,
s4xx: $s4xx,
s5xx: $s5xx,
requests: (int) data_get($row, 'requests', $s2xx + $s3xx + $s4xx + $s5xx),
bytesIn: (int) data_get($row, 'bytes_in', 0),
bytesOut: (int) data_get($row, 'bytes_out', 0),
uniqueVisitors: (int) data_get($row, 'unique_visitors', 0),
p95: (float) data_get($row, 'p95', 0),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DatabaseImportFinished
{
use Dispatchable, SerializesModels;
public function __construct(public readonly array $data) {}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DnsRecordConfigurationFinished implements ShouldBroadcastNow
{
use Dispatchable, SerializesModels;
public function __construct(
public int $teamId,
public ?string $resourceType,
public int|string|null $resourceId,
public string $hostname,
public bool $successful,
public string $credential,
public string $message,
) {}
public function broadcastOn(): array
{
return [new PrivateChannel("team.{$this->teamId}")];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class DnsRecordConflictException extends RuntimeException
{
public function __construct(
public readonly string $providerRecordId,
public readonly string $currentValue,
public readonly string $proposedValue,
) {
parent::__construct("DNS record already points to {$currentValue}.");
}
}
+7 -1
View File
@@ -243,12 +243,18 @@ class SshMultiplexingHelper
$delimiter = base64_encode(Hash::make($command));
$command = str_replace($delimiter, '', $command);
$remoteShellCommand = self::remoteShellCommand();
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
}
private static function remoteShellCommand(): string
{
return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi';
}
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\IntegrationToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ApplicationSecretManagerController extends Controller
{
#[OA\Patch(
summary: 'Configure Application Secret Manager',
description: 'Configure the secret manager source used by an application.',
path: '/applications/{uuid}/secret-manager',
operationId: 'configure-application-secret-manager',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['integration_token_uuid'],
properties: [
new OA\Property(property: 'integration_token_uuid', type: 'string'),
new OA\Property(property: 'settings', type: 'object'),
],
),
),
responses: [
new OA\Response(response: 200, description: 'Secret manager configured.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
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;
}
$application = Application::ownedByCurrentTeamAPI($teamId)
->where('uuid', $request->route('uuid'))
->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$body = $request->json()->all();
$token = IntegrationToken::query()
->where('team_id', $teamId)
->where('uuid', $body['integration_token_uuid'] ?? '')
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
->first();
if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) {
return response()->json(['message' => 'Secret manager integration token not found.'], 404);
}
$rules = [
'integration_token_uuid' => ['required', 'string'],
'settings' => ['sometimes', 'array'],
];
$rules += match ($token->provider) {
'doppler' => $token->dopplerTokenType() === 'service_account' ? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
] : [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']);
if ($validator->fails() || $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);
}
$settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value));
$application->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
auditLog('api.application.secret_manager.updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'integration_token_uuid' => $token->uuid,
]);
return response()->json([
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
'settings' => $settings ?: null,
]);
}
}
@@ -10,6 +10,7 @@ use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
@@ -61,6 +62,7 @@ class ApplicationsController extends Controller
'gpu_options',
'is_consistent_container_name_enabled',
'custom_internal_name',
'custom_container_name_prefix',
];
private const BOOLEAN_APPLICATION_SETTING_FIELDS = [
@@ -153,9 +155,26 @@ class ApplicationsController extends Controller
: $request->input($field);
}
if (array_key_exists('custom_container_name_prefix', $settings)) {
$settings['custom_container_name_prefix'] = str($settings['custom_container_name_prefix'])->slug()->value() ?: null;
}
return $settings;
}
private function containerNamePrefixValidationResponse(array $settings, Server $server, ?Application $application = null): ?JsonResponse
{
$prefix = $settings['custom_container_name_prefix'] ?? null;
if (! filled($prefix) || ! ApplicationSetting::isContainerNamePrefixInUse($prefix, $server, $application?->id)) {
return null;
}
return response()->json([
'message' => 'Validation failed.',
'errors' => ['custom_container_name_prefix' => ['This container name prefix is already in use by another application.']],
], 422);
}
private function applyApplicationSettings(Application $application, array $settings): void
{
if ($settings === []) {
@@ -393,6 +412,7 @@ class ApplicationsController extends Controller
'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.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'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.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -587,6 +607,7 @@ class ApplicationsController extends Controller
'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.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'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.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -781,6 +802,7 @@ class ApplicationsController extends Controller
'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.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'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.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -946,6 +968,7 @@ class ApplicationsController extends Controller
'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.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'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.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -1107,6 +1130,7 @@ class ApplicationsController extends Controller
'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.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'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.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -1335,6 +1359,9 @@ class ApplicationsController extends Controller
], 422);
}
}
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $destination->server)) {
return $prefixValidation;
}
if ($type === 'public') {
$validationRules = [
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
@@ -2969,6 +2996,7 @@ class ApplicationsController extends Controller
'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.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'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.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
@@ -3142,6 +3170,9 @@ class ApplicationsController extends Controller
}
$applicationSettings = $this->applicationSettingsFromRequest($request);
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) {
return $prefixValidation;
}
$requestedBuildPack = $request->input('build_pack', $application->build_pack);
if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') {
return response()->json([
@@ -3395,7 +3426,7 @@ class ApplicationsController extends Controller
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
}
$application->save();
$application->withoutAuditLogging(fn () => $application->save());
auditLog('api.application.updated', [
'team_id' => $teamId,
@@ -5903,14 +5934,6 @@ class ApplicationsController extends Controller
return response()->json(['message' => $result['message']], 200);
}
auditLog('api.application.rollback', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid,
'commit' => $commit,
]);
return response()->json([
'message' => 'Rollback deployment queued.',
'deployment_uuid' => $deployment_uuid,
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AuditEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class AuditEventsController extends Controller
{
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->user()->isAdminOfTeam($teamId)) {
return response()->json(['message' => 'Only team admins and owners can view audit logs.'], 403);
}
$validator = Validator::make($request->all(), [
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'page' => ['sometimes', 'integer', 'min:1'],
'search' => ['sometimes', 'nullable', 'string', 'max:255'],
'action' => ['sometimes', 'nullable', 'string', 'max:255'],
'source' => ['sometimes', 'nullable', 'string', Rule::in(['all', 'ui', 'api', 'mcp', 'webhook', 'system', 'scheduler'])],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$validated = $validator->validated();
$perPage = (int) ($validated['per_page'] ?? 25);
$search = trim((string) ($validated['search'] ?? ''));
$canReadSensitive = $request->attributes->get('can_read_sensitive', false) === true;
$events = AuditEvent::query()
->select([
'id',
'team_id',
'event',
'source',
'action',
'actor_type',
'actor_id',
'actor_name',
'resource_type',
'resource_uuid',
'resource_name',
'description',
'created_at',
])
->when($canReadSensitive, fn ($query) => $query->addSelect([
'actor_email',
'actor_token_id',
'actor_token_name',
'metadata',
'ip_address',
'user_agent',
]))
->visibleToTeam($teamId)
->filtered(
search: $search,
action: (string) ($validated['action'] ?? 'all'),
source: (string) ($validated['source'] ?? 'all'),
searchSensitiveFields: $canReadSensitive,
)
->latestFirst()
->paginate($perPage);
return response()->json(serializeApiResponse($events));
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Actions\Database\StartDatabaseImport;
use App\Support\DatabaseBackupFileValidator;
use App\Support\DatabaseImport\DatabaseImportException;
use App\Support\DatabaseImport\DatabaseImportSource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
use Spatie\Activitylog\Models\Activity;
trait HandlesDatabaseImportsApi
{
protected function uploadDatabaseImport(Request $request, Model $resource, int $teamId): JsonResponse
{
$this->authorize('uploadBackup', $resource);
$validator = Validator::make($request->all(), ['upload_id' => ['required', 'uuid'], 'file' => ['required', 'file']]);
if ($validator->fails()) {
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
}
$originalName = $request->file('file')?->getClientOriginalName();
if (! $originalName || ! DatabaseBackupFileValidator::hasAllowedExtension($originalName)) {
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Unsupported backup file extension.']]], 422);
}
if ((int) $request->input('dzTotalFilesize', 0) > StartDatabaseImport::MAX_BYTES) {
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['The backup exceeds the 10 GiB limit.']]], 422);
}
$request->merge(['dzuuid' => $request->input('dzuuid', $request->string('upload_id')->value())]);
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
$save = $receiver->receive();
if (! $save->isFinished()) {
return response()->json(['upload_id' => $request->string('upload_id')->value(), 'done' => $save->handler()->getPercentageDone(), 'status' => true]);
}
$file = $save->getFile();
if (! $file instanceof UploadedFile || ! DatabaseBackupFileValidator::isUploadAllowed($file, StartDatabaseImport::MAX_BYTES)) {
@unlink($file->getPathname());
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Uploaded file failed validation.']]], 422);
}
$mimeType = $file->getMimeType();
$size = $file->getSize();
$directory = "upload/imports/{$teamId}/{$resource->uuid}/{$request->string('upload_id')->value()}";
Storage::makeDirectory($directory);
$file->move(Storage::path($directory), 'restore');
return response()->json(['upload_id' => $request->string('upload_id')->value(), 'filename' => $originalName, 'mime_type' => $mimeType, 'size' => $size], 201);
}
protected function startDatabaseImport(Request $request, Model $resource, int $teamId, string $statusRoute, array $routeParameters): JsonResponse
{
$this->authorize('update', $resource);
$payload = $request->json()->all() ?: $request->request->all();
$allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing'];
$validator = Validator::make($payload, [
'source' => ['required', Rule::in(['upload', 's3', 'server'])],
'upload_id' => ['required_if:source,upload', 'prohibited_unless:source,upload', 'uuid'],
's3_storage_uuid' => ['required_if:source,s3', 'prohibited_unless:source,s3', 'string'],
'path' => ['required_if:source,s3,server', 'prohibited_if:source,upload', 'string', 'max:4096'],
'dump_all' => ['sometimes', 'boolean'],
'replace_existing' => ['sometimes', 'boolean'],
]);
$extraFields = array_diff(array_keys($payload), $allowed);
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);
}
try {
$source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false));
$activity = app(StartDatabaseImport::class)->handle($resource, $source, $teamId);
} catch (DatabaseImportException $exception) {
return response()->json(['message' => $exception->getMessage()], $exception->status);
}
auditLog('api.database.import_started', [
'team_id' => $teamId,
'database_uuid' => $resource->uuid,
'database_name' => $resource->name,
'source' => $source->type,
'replace_existing' => $source->replaceExisting,
'activity_id' => $activity->id,
]);
$url = route($statusRoute, [...$routeParameters, 'activity_id' => $activity->id], false);
return response()->json(['id' => $activity->id, 'status' => data_get($activity, 'properties.status'), 'message' => 'Database import queued.', 'status_url' => $url], 202)->header('Location', $url);
}
protected function showDatabaseImport(Model $resource, int $teamId, int $activityId): JsonResponse
{
$this->authorize('view', $resource);
$activity = Activity::query()->whereKey($activityId)
->where('properties->team_id', $teamId)
->where('properties->type_uuid', $resource->uuid)
->where('properties->operation', 'database_import')->first();
if (! $activity) {
return response()->json(['message' => 'Database import not found.'], 404);
}
$status = data_get($activity, 'properties.status');
$terminal = in_array($status, ['finished', 'error', 'killed', 'cancelled', 'closed'], true);
return response()->json([
'id' => $activity->id,
'status' => $status,
'exit_code' => data_get($activity, 'properties.exitCode'),
'output' => remove_iip(RunRemoteProcess::decodeOutput($activity)),
'created_at' => $activity->created_at,
'updated_at' => $activity->updated_at,
'finished_at' => $terminal ? $activity->updated_at : null,
]);
}
}
@@ -32,8 +32,87 @@ use OpenApi\Attributes as OA;
class DatabasesController extends Controller
{
use Concerns\HandlesDatabaseImportsApi;
use Concerns\HandlesTagsApi;
#[OA\Post(
path: '/databases/{uuid}/imports/uploads',
operationId: 'upload-database-import',
summary: 'Upload database import',
security: [['bearerAuth' => []]],
tags: ['Databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 201, description: 'Upload completed'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function upload_import(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
return $database ? $this->uploadDatabaseImport($request, $database, $teamId) : response()->json(['message' => 'Database not found.'], 404);
}
#[OA\Post(
path: '/databases/{uuid}/imports',
operationId: 'create-database-import',
summary: 'Import database backup',
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')),
security: [['bearerAuth' => []]],
tags: ['Databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 202, description: 'Import queued'),
new OA\Response(response: 409, description: 'Import already active'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_import(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
return $database ? $this->startDatabaseImport($request, $database, $teamId, 'api.databases.imports.show', ['uuid' => $uuid]) : response()->json(['message' => 'Database not found.'], 404);
}
#[OA\Get(
path: '/databases/{uuid}/imports/{activity_id}',
operationId: 'get-database-import',
summary: 'Get database import status',
security: [['bearerAuth' => []]],
tags: ['Databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'activity_id', in: 'path', required: true, description: 'Import activity ID.', schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 200, description: 'Import status', content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportStatus')),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function show_import(Request $request, string $uuid, int $activity_id): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
return $database ? $this->showDatabaseImport($database, $teamId, $activity_id) : response()->json(['message' => 'Database not found.'], 404);
}
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\IntegrationToken;
use App\Services\IntegrationTokenValidator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class IntegrationTokensController extends Controller
{
#[OA\Post(
summary: 'Create Secret Manager Token',
description: 'Create and validate a Doppler, Infisical, or Vault integration token.',
path: '/security/integration-tokens',
operationId: 'create-secret-manager-integration-token',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['provider', 'name', 'token'],
properties: [
new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'token', type: 'string'),
new OA\Property(property: 'metadata', type: 'object'),
],
),
),
responses: [
new OA\Response(response: 201, description: 'Integration token created.'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', IntegrationToken::class);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$body = $request->json()->all();
$rules = [
'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'metadata' => ['sometimes', 'array'],
];
if (($body['provider'] ?? null) === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
} elseif (($body['provider'] ?? null) === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
} elseif (($body['provider'] ?? null) === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']);
if ($validator->fails() || $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);
}
$validated = $validator->validated();
$metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value));
if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) {
return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400);
}
$integrationToken = IntegrationToken::query()->create([
'team_id' => $teamId,
'provider' => $validated['provider'],
'name' => $validated['name'],
'token' => $validated['token'],
'capabilities' => ['secrets'],
'metadata' => $metadata ?: null,
]);
auditLog('api.integration_token.created', [
'team_id' => $teamId,
'integration_token_uuid' => $integrationToken->uuid,
'provider' => $integrationToken->provider,
]);
return response()->json(['uuid' => $integrationToken->uuid], 201);
}
}
+24
View File
@@ -12,6 +12,30 @@ use OpenApi\Attributes as OA;
securityScheme: 'bearerAuth',
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.')]
#[OA\Components(
schemas: [
new OA\Schema(
schema: 'DatabaseImportRequest',
oneOf: [
new OA\Schema(required: ['source', 'upload_id'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['upload']), new OA\Property(property: 'upload_id', type: 'string', format: 'uuid'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['s3']), new OA\Property(property: 's3_storage_uuid', type: 'string'), new OA\Property(property: 'path', type: 'string'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
new OA\Schema(required: ['source', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['server']), new OA\Property(property: 'path', type: 'string', example: '/var/backups/database.sql.gz'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
],
type: 'object',
),
new OA\Schema(
schema: 'DatabaseImportStatus',
type: 'object',
properties: [
new OA\Property(property: 'id', type: 'integer'),
new OA\Property(property: 'status', type: 'string', enum: ['queued', 'in_progress', 'finished', 'error', 'killed', 'cancelled', 'closed']),
new OA\Property(property: 'exit_code', type: 'integer', nullable: true),
new OA\Property(property: 'output', type: 'string'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'finished_at', type: 'string', format: 'date-time', nullable: true),
],
),
],
responses: [
new OA\Response(
response: 400,
@@ -271,12 +271,6 @@ class ProjectController extends Controller
'team_id' => $teamId,
]);
auditLog('api.project.created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
]);
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -396,13 +390,6 @@ class ProjectController extends Controller
$project->update($request->only($allowedFields));
auditLog('api.project.updated', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -482,16 +469,8 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
$projectUuid = $project->uuid;
$projectName = $project->name;
$project->delete();
auditLog('api.project.deleted', [
'team_id' => $teamId,
'project_uuid' => $projectUuid,
'project_name' => $projectName,
]);
return response()->json(['message' => 'Project deleted.']);
}
@@ -19,6 +19,13 @@ class ServerSentinelController extends Controller
'sentinel_metrics_history_days',
'sentinel_push_interval_seconds',
'sentinel_custom_url',
'traffic_topn',
'traffic_sample_threshold',
'traffic_retention_1h_days',
'traffic_retention_1d_days',
'is_geoip_enabled',
'geoip_refresh_days',
'geoip_maxmind_license_key',
];
private function findServerForTeam(int $teamId, string $uuid): ?Server
@@ -42,11 +49,18 @@ class ServerSentinelController extends Controller
'sentinel_metrics_history_days' => (int) $settings->sentinel_metrics_history_days,
'sentinel_push_interval_seconds' => (int) $settings->sentinel_push_interval_seconds,
'sentinel_updated_at' => $server->sentinel_updated_at,
'traffic_topn' => (int) $settings->traffic_topn,
'traffic_sample_threshold' => (int) $settings->traffic_sample_threshold,
'traffic_retention_1h_days' => (int) $settings->traffic_retention_1h_days,
'traffic_retention_1d_days' => (int) $settings->traffic_retention_1d_days,
'is_geoip_enabled' => (bool) $settings->is_geoip_enabled,
'geoip_refresh_days' => (int) $settings->geoip_refresh_days,
];
if ($this->canReadSensitive()) {
$payload['sentinel_token'] = $settings->sentinel_token;
$payload['sentinel_custom_url'] = $settings->sentinel_custom_url;
$payload['geoip_maxmind_license_key'] = $settings->geoip_maxmind_license_key;
}
return $payload;
@@ -77,6 +91,13 @@ class ServerSentinelController extends Controller
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer'),
new OA\Property(property: 'sentinel_custom_url', type: 'string', description: 'Only present with read:sensitive.'),
new OA\Property(property: 'sentinel_updated_at', type: 'string', nullable: true),
new OA\Property(property: 'traffic_topn', type: 'integer'),
new OA\Property(property: 'traffic_sample_threshold', type: 'integer'),
new OA\Property(property: 'traffic_retention_1h_days', type: 'integer'),
new OA\Property(property: 'traffic_retention_1d_days', type: 'integer'),
new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
new OA\Property(property: 'geoip_refresh_days', type: 'integer'),
new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', description: 'Only present with read:sensitive.'),
],
type: 'object',
),
@@ -124,6 +145,13 @@ class ServerSentinelController extends Controller
new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer', minimum: 1),
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer', minimum: 10),
new OA\Property(property: 'sentinel_custom_url', type: 'string', nullable: true),
new OA\Property(property: 'traffic_topn', type: 'integer', minimum: 1),
new OA\Property(property: 'traffic_sample_threshold', type: 'integer', minimum: 0),
new OA\Property(property: 'traffic_retention_1h_days', type: 'integer', minimum: 1),
new OA\Property(property: 'traffic_retention_1d_days', type: 'integer', minimum: 1),
new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
new OA\Property(property: 'geoip_refresh_days', type: 'integer', minimum: 1),
new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', nullable: true),
],
type: 'object',
),
@@ -163,6 +191,13 @@ class ServerSentinelController extends Controller
'sentinel_metrics_history_days' => 'integer|min:1',
'sentinel_push_interval_seconds' => 'integer|min:10',
'sentinel_custom_url' => 'nullable|url',
'traffic_topn' => 'integer|min:1',
'traffic_sample_threshold' => 'integer|min:0',
'traffic_retention_1h_days' => 'integer|min:1',
'traffic_retention_1d_days' => 'integer|min:1',
'is_geoip_enabled' => 'boolean',
'geoip_refresh_days' => 'integer|min:1',
'geoip_maxmind_license_key' => 'nullable|string|max:255',
]);
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
@@ -18,6 +18,84 @@ use OpenApi\Attributes as OA;
class ServiceDatabasesController extends Controller
{
use Concerns\HandlesDatabaseImportsApi;
#[OA\Post(
path: '/services/{uuid}/databases/{database_uuid}/imports/uploads',
operationId: 'upload-service-database-import',
summary: 'Upload service database import',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 201, description: 'Upload completed'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function upload_import(Request $request): JsonResponse
{
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->uploadDatabaseImport($request, $database, $teamId));
}
#[OA\Post(
path: '/services/{uuid}/databases/{database_uuid}/imports',
operationId: 'create-service-database-import',
summary: 'Import service database backup',
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')),
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 202, description: 'Import queued'),
new OA\Response(response: 409, description: 'Import already active'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_import(Request $request): JsonResponse
{
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->startDatabaseImport($request, $database, $teamId, 'api.service-databases.imports.show', ['uuid' => $request->route('uuid'), 'database_uuid' => $database->uuid]));
}
#[OA\Get(
path: '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}',
operationId: 'get-service-database-import',
summary: 'Get service database import status',
security: [['bearerAuth' => []]],
tags: ['Service databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'activity_id', in: 'path', description: 'Import activity ID.', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 200, description: 'Import status', content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportStatus')),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function show_import(Request $request): JsonResponse
{
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->showDatabaseImport($database, $teamId, (int) $request->route('activity_id')));
}
private function withImportDatabase(Request $request, callable $callback): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
$database = $service ? $this->resolveServiceDatabase($request, $service) : null;
return $database ? $callback($database, $teamId) : response()->json(['message' => 'Service database not found.'], 404);
}
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
{
$serviceDatabase->makeHidden([
+37 -24
View File
@@ -2,41 +2,27 @@
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use App\Models\OauthSetting;
use App\Services\Auth\OauthLoginService;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
class OauthController extends Controller
{
public function redirect(string $provider)
{
$socialite_provider = get_socialite_provider($provider);
$oauthSetting = $this->enabledProvider($provider);
$socialiteProvider = get_socialite_provider($oauthSetting->provider);
return $socialite_provider->redirect();
return $socialiteProvider->redirect();
}
public function callback(string $provider)
public function callback(string $provider, OauthLoginService $oauthLoginService)
{
try {
$oauthUser = get_socialite_provider($provider)->user();
$email = trim((string) $oauthUser->email);
if ($email === '') {
abort(403, 'OAuth provider did not return an email address');
}
$email = strtolower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
abort(403, 'Registration is disabled');
}
$user = User::create([
'name' => $oauthUser->name,
'email' => $email,
]);
}
Auth::login($user);
$oauthSetting = $this->enabledProvider($provider);
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
$oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting);
$team = $user->resolveStoredTeam();
if (! $team && $user->teams()->count() === 0) {
@@ -48,9 +34,36 @@ class OauthController extends Controller
return redirect('/');
} catch (\Exception $e) {
$this->logCallbackFailure($provider, $e);
$errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback';
return redirect()->route('login')->withErrors([__($errorCode)]);
}
}
private function logCallbackFailure(string $provider, \Throwable $exception): void
{
Log::error('OAuth callback failed.', [
'provider' => $provider,
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'request_error' => request()->query('error'),
'request_error_description' => request()->query('error_description'),
'has_code' => request()->query->has('code'),
'has_state' => request()->query->has('state'),
'ip' => request()->ip(),
'exception' => $exception,
]);
}
private function enabledProvider(string $provider): OauthSetting
{
$oauthSetting = OauthSetting::where('provider', $provider)->first();
if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) {
throw new HttpException(403, 'OAuth provider is not enabled');
}
return $oauthSetting;
}
}
+2
View File
@@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
@@ -59,6 +60,7 @@ class Kernel extends HttpKernel
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
InvokeDeferredCallbacks::class,
];
+164 -36
View File
@@ -19,6 +19,7 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use App\Support\RemoteSecretReferences;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
@@ -147,6 +148,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_args;
/** @var array<string, string>|null */
private ?array $remote_secrets_cache = null;
private $env_nixpacks_args;
private $env_railpack_args;
@@ -269,7 +273,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->container_name = $this->resolveContainerName();
$this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id);
$this->saved_outputs = collect();
@@ -335,7 +339,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($containerName === 'coolify-proxy') {
continue;
}
if (preg_match('/-(\d{12})/', $containerName)) {
if (isGeneratedContainerName($containerName)) {
continue;
}
$containerIp = data_get($container, 'IPv4Address');
@@ -1309,6 +1313,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return true;
}
if ($this->has_remote_buildtime_secret_references()) {
$this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.');
return false;
}
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
if (! $configurationDiff->requiresBuild()) {
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
@@ -1336,6 +1345,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return false;
}
private function has_remote_buildtime_secret_references(): bool
{
$environmentVariables = $this->pull_request_id === 0
? $this->application->environment_variables()
: $this->application->environment_variables_preview();
return $environmentVariables
->where('is_buildtime', true)
->get(['value'])
->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value));
}
private function check_image_locally_or_remotely()
{
$this->execute_remote_command([
@@ -1357,6 +1378,101 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
/**
* Fetch the secrets from the application's secret manager source. Values
* live only in memory during the deployment and in the generated .env on
* the server — they are never persisted in the Coolify database. Fetched
* lazily (only when a variable references a secret), once per deployment.
* A fetch failure fails the deployment.
*
* @return array<string, string>
*/
private function remote_secrets(): array
{
if ($this->remote_secrets_cache !== null) {
return $this->remote_secrets_cache;
}
$link = $this->application->secretManagerLink()->with('integrationToken')->first();
if (! $link) {
throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.');
}
$provider = $link->integrationToken->providerName();
$tokenName = $link->integrationToken->name;
try {
$secrets = $link->fetchSecrets();
} catch (Throwable $e) {
$this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr');
throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets.");
}
$this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}).");
return $this->remote_secrets_cache = $secrets;
}
/**
* Replace {{vault.KEY}} references with values from the configured secret
* manager source. Missing keys fail the deployment with a
* list — changing the source never re-checks references, so this is the
* moment problems surface.
*/
private function substitute_remote_secrets(string $value, string $envKey): string
{
$secrets = $this->remote_secrets();
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
if ($missing !== []) {
$message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey}).";
$this->application_deployment_queue->addLogEntry($message, 'stderr');
throw new DeploymentException($message.' Check the secret manager source of this application.');
}
return RemoteSecretReferences::substitute($value, $secrets);
}
/**
* Resolve shared variables, then secret references, in a raw variable value.
*/
private function resolve_environment_variable_raw(EnvironmentVariable $env): string
{
$value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer);
return $this->substitute_remote_secrets($value ?? '', $env->key);
}
/**
* Resolve a runtime variable to its dotenv representation. Values with
* secret references are substituted and written as literals.
*/
private function resolve_environment_variable(EnvironmentVariable $env): ?string
{
if (! RemoteSecretReferences::containsReference($env->value)) {
return $env->getResolvedValueWithServer($this->mainServer);
}
return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env));
}
/**
* Format a remote secret value for the runtime .env file (dotenv syntax read
* by docker compose). Values are treated as literals — no interpolation.
*/
private function format_remote_secret_value(string $value): string
{
if (! str_contains($value, "'")) {
return "'".$value."'";
}
// Fall back to double quotes; $$ escapes compose interpolation.
return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"';
}
private function generate_runtime_environment_variables()
{
$envs = collect([]);
@@ -1425,7 +1541,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
// Check for PORT environment variable mismatch with ports_exposes
@@ -1492,7 +1608,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
// Fall back to production env vars for keys not overridden by preview vars,
@@ -1506,7 +1622,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
}
@@ -1614,6 +1730,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
'skip_command_log' => true,
]
);
@@ -1632,6 +1749,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
'skip_command_log' => true,
]
);
$this->server = $this->build_server;
@@ -1639,6 +1757,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
'skip_command_log' => true,
]
);
}
@@ -1765,6 +1884,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
if (RemoteSecretReferences::containsReference($env->value)) {
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1820,6 +1945,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
if (RemoteSecretReferences::containsReference($env->value)) {
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1879,6 +2010,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string
{
try {
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
@@ -2072,7 +2204,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->write_deployment_configurations();
$this->server = $this->mainServer;
}
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if (count($this->application->ports_mappings_array) > 0) {
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
@@ -2080,7 +2212,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
}
if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
if ((bool) $this->application->settings->is_consistent_container_name_enabled && str($this->application->settings->custom_internal_name)->isNotEmpty()) {
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
}
if ($this->pull_request_id !== 0) {
@@ -2106,19 +2238,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function resolveContainerName(): string
{
if (str($this->application->settings->custom_internal_name)->isEmpty()) {
return generateApplicationContainerName($this->application, $this->pull_request_id);
}
if ($this->pull_request_id === 0) {
return $this->application->settings->custom_internal_name;
}
return addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
}
private function health_check()
{
try {
@@ -2789,6 +2908,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
{
if (RemoteSecretReferences::containsReference($environmentVariable->value)) {
$resolved = $this->resolve_environment_variable_raw($environmentVariable);
return $resolved === '' ? null : $resolved;
}
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
if (is_null($resolvedValue) || $resolvedValue === '') {
return null;
@@ -3332,7 +3457,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
? $this->resolve_environment_variable_raw($env)
: $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
@@ -3348,7 +3475,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
? $this->resolve_environment_variable_raw($env)
: $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
@@ -4169,7 +4298,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
try {
$this->application_deployment_queue->addLogEntry('Removing old containers.');
if ($this->newVersionIsHealthy || $force) {
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
if ($this->application->settings->is_consistent_container_name_enabled) {
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
$this->containerNamesToRemove($containers)->each(function (string $containerName) {
$this->graceful_shutdown_container($containerName);
@@ -4442,7 +4571,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$secrets_string = $variables
->map(function ($env) {
return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}";
return "{$env->key}={$this->resolve_environment_variable($env)}";
})
->sort()
->implode('|');
@@ -4508,7 +4637,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
}
}
// Add Coolify variables as ARGs
@@ -4530,7 +4659,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
}
}
// Add Coolify variables as ARGs
@@ -4544,6 +4673,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
if ($argsToInsert->isNotEmpty()) {
$environmentVariables = $envs->mapWithKeys(function ($environmentVariable) {
return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))];
});
$secretsHash = $this->generate_secrets_hash($environmentVariables);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}");
}
// Development logging to show what ARGs are being injected
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
@@ -4565,11 +4702,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
$envs_mapped = $envs->mapWithKeys(function ($env) {
return [$env->key => $env->getResolvedValueWithServer($this->mainServer)];
});
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
@@ -4578,11 +4710,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'ignore_errors' => true,
'skip_command_log' => true,
]);
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Jobs;
use App\Events\DnsRecordConfigurationFinished;
use App\Models\DnsProviderZone;
use App\Services\Dns\CloudflareDnsProvider;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\SerializesModels;
use Throwable;
class ConfigureDnsRecordJob implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 30;
public function __construct(
public int $teamId,
public int $zoneId,
public ?string $resourceType,
public int|string|null $resourceId,
public string $hostname,
public string $content,
) {}
public function handle(CloudflareDnsProvider $provider): void
{
$zone = DnsProviderZone::query()
->with('integrationToken')
->whereKey($this->zoneId)
->whereHas('integrationToken', fn ($query) => $query->where('team_id', $this->teamId))
->firstOrFail();
try {
$provider->createRecord($zone, $this->hostname, $this->content, $this->resource());
DnsRecordConfigurationFinished::dispatch(
$this->teamId,
$this->resourceType,
$this->resourceId,
$this->hostname,
true,
$zone->integrationToken->name,
"DNS record added for {$this->hostname}.",
);
} catch (Throwable $exception) {
DnsRecordConfigurationFinished::dispatch(
$this->teamId,
$this->resourceType,
$this->resourceId,
$this->hostname,
false,
$zone->integrationToken->name,
$exception->getMessage(),
);
}
}
public function failed(?Throwable $exception): void
{
DnsRecordConfigurationFinished::dispatch(
$this->teamId,
$this->resourceType,
$this->resourceId,
$this->hostname,
false,
'',
'The DNS zone is no longer available.',
);
}
private function resource(): ?Model
{
$resourceClass = $this->resourceType === null ? null : (Relation::getMorphedModel($this->resourceType) ?? $this->resourceType);
if ($resourceClass === null || $this->resourceId === null || ! is_subclass_of($resourceClass, Model::class)) {
return null;
}
return $resourceClass::query()->find($this->resourceId);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Jobs;
use App\Actions\Database\StartClickhouse;
use App\Actions\Database\StartDragonfly;
use App\Actions\Database\StartKeydb;
use App\Actions\Database\StartMariadb;
use App\Actions\Database\StartMongodb;
use App\Actions\Database\StartMysql;
use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StartRedis;
use App\Enums\ProcessStatus;
use App\Events\DatabaseStatusChanged;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use 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\SerializesModels;
use Spatie\Activitylog\Models\Activity;
use Throwable;
class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 600;
public function __construct(
public string $databaseClass,
public int $databaseId,
public int $teamId,
public int $activityId,
public ?int $userId,
) {
$this->onQueue(deployment_queue());
}
public function handle(): void
{
$database = $this->databaseClass::query()->findOrFail($this->databaseId);
abort_unless((int) $database->team()->id === $this->teamId, 403);
$activity = Activity::query()->findOrFail($this->activityId);
match ($database->getMorphClass()) {
StandalonePostgresql::class => StartPostgresql::run($database, $activity),
StandaloneRedis::class => StartRedis::run($database, $activity),
StandaloneMongodb::class => StartMongodb::run($database, $activity),
StandaloneMysql::class => StartMysql::run($database, $activity),
StandaloneMariadb::class => StartMariadb::run($database, $activity),
StandaloneKeydb::class => StartKeydb::run($database, $activity),
StandaloneDragonfly::class => StartDragonfly::run($database, $activity),
StandaloneClickhouse::class => StartClickhouse::run($database, $activity),
};
event(new DatabaseStatusChanged($this->userId));
}
public function failed(?Throwable $exception): void
{
try {
$activity = Activity::query()->find($this->activityId);
if (! $activity) {
return;
}
$activity->properties = $activity->properties->merge([
'status' => ProcessStatus::ERROR->value,
'error' => 'Database start failed.',
'failed_at' => now()->toIso8601String(),
]);
$activity->save();
} finally {
event(new DatabaseStatusChanged($this->userId));
}
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Listeners;
use App\Events\DatabaseImportFinished;
use App\Models\Server;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class CleanupDatabaseImport implements ShouldQueue
{
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [5, 15, 30];
public function handle(DatabaseImportFinished $event): void
{
$commands = $this->commands($event->data);
$server = Server::query()->find($event->data['serverId'] ?? null);
if ($server && $commands !== []) {
instant_remote_process($commands, $server);
}
}
/**
* @param array<string, mixed> $data
* @return list<string>
*/
public function commands(array $data): array
{
$commands = [];
if (filled($data['containerName'] ?? null)) {
$commands[] = 'docker rm -f '.escapeshellarg($data['containerName']).' 2>/dev/null || true';
}
if (isSafeTmpPath($data['serverTmpPath'] ?? null)) {
$commands[] = 'rm -f '.escapeshellarg($data['serverTmpPath']).' 2>/dev/null || true';
}
if (isSafeTmpPath($data['credentialTmpPath'] ?? null)) {
$commands[] = 'rm -f '.escapeshellarg($data['credentialTmpPath']).' 2>/dev/null || true';
}
if (filled($data['container'] ?? null)) {
foreach (['containerTmpPath', 'scriptPath'] as $key) {
if (isSafeTmpPath($data[$key] ?? null)) {
$commands[] = 'docker exec '.escapeshellarg($data['container']).' rm -f '.escapeshellarg($data[$key]).' 2>/dev/null || true';
}
}
}
return $commands;
}
public function failed(DatabaseImportFinished $event, Throwable $exception): void
{
Log::error('Database import cleanup failed', [
'serverId' => $event->data['serverId'] ?? null,
'containerName' => $event->data['containerName'] ?? null,
'error' => $exception->getMessage(),
]);
}
}
+603
View File
@@ -0,0 +1,603 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\BuildsTrafficChartPayload;
use App\Models\Application;
use App\Models\Server;
use App\Services\SentinelTrafficClient;
use App\Services\TrafficAnalyticsAggregator;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Lazy;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Lazy]
class Analytics extends Component
{
use BuildsTrafficChartPayload;
public string $chartId = 'global-analytics';
public ?string $scopedServerUuid = null;
/** Traffic-enabled servers owned by the current team. */
public Collection $servers;
/** @var array<string, string> uuid => name, for the server filter */
public array $serverOptions = [];
/** @var array<string, string> uuid => name, for the application filter (scoped to the selected server) */
public array $appOptions = [];
/**
* Listbox options for the application filter, grouped under project headers so
* it's clear which application belongs to which project.
*
* @var array<int, array{value: string, label: string, header?: bool}>
*/
public array $appGroupedOptions = [];
#[Url(as: 'range')]
public string $range = '24h';
#[Url(as: 'server')]
public string $serverUuid = '';
#[Url(as: 'app')]
public string $appUuid = '';
// Realtime refresh; off by default (click "Live" to arm it). Only meaningful on the
// 24h range, which matches the 60s Sentinel cache TTL.
public bool $live = false;
public ?array $overview = null;
public bool $latencyApproximate = false;
public bool $uniquesApproximate = false;
/** @var array<int, array<string, mixed>> */
public array $topApps = [];
/** @var array<int, array<string, mixed>> */
public array $topHosts = [];
/** @var array<int, array<string, mixed>> */
public array $topPaths = [];
/** @var array<string, array<int, array<string, mixed>>> */
public array $breakdowns = [];
public ?string $attribution = null;
/**
* Per-bucket status-class time series for the stacked area chart, summed across
* target servers and sorted by bucket. Empty when no target Sentinel exposes the
* series endpoint (older builds), which flips the chart back to the status donut.
*
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
*/
public array $series = [];
public bool $hasSeries = false;
/**
* Servers that could run traffic analytics but have it off — drives the nudge banner.
*
* @var array<int, array{uuid: string, name: string}>
*/
public array $eligibleDisabledServers = [];
public string $nudgeKey = '';
/**
* Upper bound on per-app overviews fetched for the leaderboard, so a server with a huge
* number of recorded apps can't reintroduce a per-app round-trip storm. Truncation is
* logged (see loadData) rather than silently swallowed.
*/
private const MAX_LEADERBOARD_APPS = 200;
/** @var array<int, string> */
protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent'];
/**
* Per-request cache of app uuid => display metadata, so resolving a name/domain/link
* for the leaderboard and path domains hits the DB at most once per app.
*
* @var array<string, array{name: string, domain: ?string, link: ?string}>
*/
protected array $appMetaCache = [];
public function mount(?string $scopedServerUuid = null): void
{
$allServers = Server::ownedByCurrentTeamCached();
$this->scopedServerUuid = $scopedServerUuid;
if ($this->scopedServerUuid !== null) {
$server = $allServers->firstWhere('uuid', $this->scopedServerUuid);
abort_if($server === null, 404);
$this->serverUuid = $server->uuid;
$this->chartId = 'server-analytics-'.$server->uuid;
$this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect();
$this->serverOptions = [$server->uuid => $server->name];
$this->eligibleDisabledServers = [];
$this->nudgeKey = '';
} else {
$this->servers = $allServers
->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled())
->values();
$this->serverOptions = $this->servers
->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name])
->all();
$eligibleDisabled = $allServers
->filter(fn (Server $server) => ! $server->isTrafficAnalyticsEnabled()
&& ! $server->isSwarm()
&& ! $server->isBuildServer())
->values();
$this->eligibleDisabledServers = $eligibleDisabled
->map(fn (Server $server) => ['uuid' => $server->uuid, 'name' => $server->name])
->all();
$this->nudgeKey = substr(md5($eligibleDisabled->pluck('uuid')->sort()->implode(',')), 0, 12);
// A bookmarked ?server= may point at a server that is no longer enabled.
if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) {
$this->serverUuid = '';
}
}
$this->refreshAppOptions();
if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) {
$this->appUuid = '';
}
if ($this->servers->isNotEmpty()) {
$this->loadData();
}
}
public function setRange(string $range): void
{
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
$this->loadData();
}
public function toggleLive(): void
{
if ($this->range !== '24h') {
return;
}
$this->live = ! $this->live;
}
#[On('trafficAnalyticsStateChanged')]
public function refreshTrafficAnalyticsState(): void
{
if ($this->scopedServerUuid === null) {
return;
}
$server = Server::ownedByCurrentTeam()->whereUuid($this->scopedServerUuid)->firstOrFail();
$this->overview = null;
$this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect();
if ($this->servers->isNotEmpty()) {
$this->refreshAppOptions();
$this->loadData();
}
}
public function isLivePollable(): bool
{
return $this->live && $this->range === '24h';
}
public function updatedServerUuid(): void
{
// Scope the app options to the newly selected server and drop an app filter
// that no longer belongs to it.
$this->refreshAppOptions();
if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) {
$this->appUuid = '';
}
$this->loadData();
}
public function updatedAppUuid(): void
{
$this->loadData();
}
protected function refreshAppOptions(): void
{
$enabledUuids = $this->servers->pluck('uuid');
$apps = Application::ownedByCurrentTeam()->with(['environment.project', 'destination.server'])->get()
->filter(function (Application $app) use ($enabledUuids): bool {
$serverUuid = $app->destination?->server?->uuid;
if (! $serverUuid || ! $enabledUuids->contains($serverUuid)) {
return false;
}
return $this->serverUuid === '' || $serverUuid === $this->serverUuid;
});
// Flat uuid => name map, used to validate a bookmarked ?app= filter.
$options = $apps->mapWithKeys(fn (Application $app) => [$app->uuid => $app->name])->all();
asort($options);
$this->appOptions = $options;
// Grouped listbox options: a header row per project, then its apps (both alpha-sorted).
$grouped = [];
$byProject = $apps
->groupBy(fn (Application $app) => (string) (data_get($app, 'environment.project.name') ?: 'Ungrouped'))
->sortKeys();
foreach ($byProject as $projectName => $projectApps) {
$grouped[] = ['value' => '__group_'.md5($projectName), 'label' => $projectName, 'header' => true];
foreach ($projectApps->sortBy('name') as $app) {
$grouped[] = ['value' => $app->uuid, 'label' => $app->name];
}
}
$this->appGroupedOptions = $grouped;
}
/**
* Servers this view should query, honoring the active server/app filters.
*/
protected function targetServers(): Collection
{
if ($this->scopedServerUuid !== null) {
return $this->servers;
}
if ($this->appUuid !== '') {
$server = Application::ownedByCurrentTeam()->whereUuid($this->appUuid)->first()
?->destination?->server;
return $server && $this->servers->contains(fn (Server $s) => $s->uuid === $server->uuid)
? collect([$server])
: collect();
}
if ($this->serverUuid !== '') {
return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values();
}
return $this->servers;
}
public function loadData(): void
{
if ($this->servers->isEmpty()) {
return;
}
[$from, $to] = $this->window();
$appKey = $this->appUuid !== '' ? $this->appUuid : null;
$servers = $this->targetServers();
$overviews = [];
$appRows = [];
$pathTotals = [];
$breakdownTotals = array_fill_keys($this->breakdownDimensions, []);
$seriesByBucket = [];
$attribution = null;
foreach ($servers as $server) {
try {
$client = $this->trafficClient($server);
// Warm every server-wide endpoint in one docker exec instead of ~15 serial
// SSH round-trips; the per-call methods below then read from cache.
$leaderboardUuids = $client->prefetchServerWide($appKey, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS);
// Per-application leaderboard only makes sense when not already filtered to one app.
if ($appKey === null && $leaderboardUuids !== []) {
if (count($leaderboardUuids) > self::MAX_LEADERBOARD_APPS) {
Log::warning('Traffic analytics leaderboard truncated', [
'server' => $server->uuid,
'total' => count($leaderboardUuids),
'shown' => self::MAX_LEADERBOARD_APPS,
]);
$leaderboardUuids = array_slice($leaderboardUuids, 0, self::MAX_LEADERBOARD_APPS);
}
// Warm the leaderboard's per-app overviews in a second batched exec.
$client->prefetchAppOverviews($leaderboardUuids, $from, $to);
}
$overviews[] = $client->overview($appKey, $from, $to);
if ($appKey === null) {
foreach ($leaderboardUuids as $uuid) {
$appOverview = $client->overview($uuid, $from, $to)->toArray();
$meta = $this->appMeta($uuid);
$appRows[] = [
'uuid' => $uuid,
'name' => $meta['name'],
'domain' => $meta['domain'],
'link' => $meta['link'],
'requests' => (int) ($appOverview['requests'] ?? 0),
'bandwidth' => (int) ($appOverview['bytesIn'] ?? 0) + (int) ($appOverview['bytesOut'] ?? 0),
];
}
}
foreach ($client->paths($appKey, $from, $to, 50) as $path) {
$data = $path->toArray();
$pathStr = (string) ($data['path'] ?? '');
// Prefer the per-path app from Sentinel; fall back to the active app filter
// (older Sentinel omits `app`, but a filtered view still knows the app).
$appId = (string) ($data['app'] ?? '');
$resolveId = $appId !== '' ? $appId : ($appKey ?? '');
// Key by (app, path) so the same path under two apps stays two rows, each
// carrying its own domain.
$key = $resolveId."\n".$pathStr;
$domain = $resolveId !== '' ? ($this->appMeta($resolveId)['domain'] ?? null) : null;
$pathTotals[$key] ??= ['path' => $pathStr, 'domain' => $domain, 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0];
$pathTotals[$key]['requests'] += (int) ($data['requests'] ?? 0);
$pathTotals[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
$pathTotals[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0);
$pathTotals[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0);
$pathTotals[$key]['p95'] = max($pathTotals[$key]['p95'], (float) ($data['p95'] ?? 0));
}
foreach ($this->breakdownDimensions as $dimension) {
foreach ($client->breakdown($appKey, $dimension, $from, $to, 50) as $row) {
$data = $row->toArray();
$value = (string) ($data['value'] ?? '');
$breakdownTotals[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0];
$breakdownTotals[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0);
$breakdownTotals[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
}
}
$attribution ??= $client->attribution();
// Per-bucket status series; summed by bucket across servers. Isolated so a
// series hiccup (or an older Sentinel lacking the endpoint) never discards a
// server's other data — an empty result simply flips the chart to the donut.
try {
foreach ($client->series($appKey, $this->range) as $bucket) {
$data = $bucket->toArray();
$ts = (int) ($data['bucket'] ?? 0);
$seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0];
$seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0);
$seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0);
$seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0);
$seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0);
$seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0);
$seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0);
$seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
// Uniques summed across servers (approximate); p95 takes the worst bucket.
$seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0);
$seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0));
}
} catch (\Throwable $e) {
// Leave this server out of the series; donut fallback covers it.
}
} catch (\Throwable $e) {
// Skip unreachable/failed servers so one bad server doesn't break the whole view.
continue;
}
}
if (empty($overviews)) {
$this->resetData();
// The chart lives under wire:ignore, so it only updates via this event — dispatch
// even when cleared so a previously-populated chart flips to its no-data state
// instead of keeping stale data.
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
return;
}
$result = TrafficAnalyticsAggregator::sumOverviews($overviews);
$this->overview = $result['overview']->toArray();
$this->latencyApproximate = $result['latencyApproximate'];
$this->uniquesApproximate = $result['uniquesApproximate'];
usort($appRows, fn ($a, $b) => $b['requests'] <=> $a['requests']);
$this->topApps = array_slice($appRows, 0, 50);
// Top hosts: fold per-app volume up to the served hostname (an app's primary
// domain). Apps without a configured FQDN collapse into one "Unknown host" row.
$hostTotals = [];
foreach ($appRows as $row) {
$host = $row['domain'] ?? '';
$hostTotals[$host] ??= ['host' => $host, 'requests' => 0, 'bandwidth' => 0];
$hostTotals[$host]['requests'] += (int) $row['requests'];
$hostTotals[$host]['bandwidth'] += (int) $row['bandwidth'];
}
$hosts = array_values($hostTotals);
usort($hosts, fn ($a, $b) => $b['requests'] <=> $a['requests']);
$this->topHosts = array_slice($hosts, 0, 50);
$paths = array_values($pathTotals);
usort($paths, fn ($a, $b) => $b['requests'] <=> $a['requests']);
$this->topPaths = array_slice($paths, 0, 50);
$breakdowns = [];
foreach ($this->breakdownDimensions as $dimension) {
$rows = array_values($breakdownTotals[$dimension]);
usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']);
$breakdowns[$dimension] = array_slice($rows, 0, 50);
}
$this->breakdowns = $breakdowns;
$this->attribution = $attribution;
ksort($seriesByBucket);
$this->series = array_values($seriesByBucket);
$this->hasSeries = $this->series !== [];
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
}
/**
* Payload for the status chart: the stacked-area time series when available,
* plus the donut totals as a fallback for older Sentinel builds.
*
* @return array<string, mixed>
*/
protected function chartPayload(): array
{
$device = $this->deviceChartData();
$overview = $this->overview ?? [];
return [
'hasSeries' => $this->hasSeries,
'range' => $this->range,
'seriesData' => [
$overview['s2xx'] ?? 0,
$overview['s3xx'] ?? 0,
$overview['s4xx'] ?? 0,
$overview['s5xx'] ?? 0,
],
'timeSeries' => [
'categories' => array_column($this->series, 'bucket'),
'requests' => $this->requestsSpark(),
's2xx' => array_column($this->series, 's2xx'),
's3xx' => array_column($this->series, 's3xx'),
's4xx' => array_column($this->series, 's4xx'),
's5xx' => array_column($this->series, 's5xx'),
],
'requestsSpark' => $this->requestsSpark(),
'sparkCategories' => array_column($this->series, 'bucket'),
'errorsSpark' => $this->errorsSpark(),
'bandwidthSpark' => $this->bandwidthSpark(),
'uniquesSpark' => $this->uniquesSpark(),
'latencySpark' => $this->latencySpark(),
'geo' => $this->geoMarkers(),
'deviceLabels' => $device['labels'],
'deviceSeries' => $device['series'],
];
}
protected function resetData(): void
{
$this->overview = null;
$this->latencyApproximate = false;
$this->uniquesApproximate = false;
$this->topApps = [];
$this->topHosts = [];
$this->topPaths = [];
$this->breakdowns = [];
$this->attribution = null;
$this->series = [];
$this->hasSeries = false;
}
public function errorRate(): float
{
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
return 0.0;
}
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
return round(($errors / $this->overview['requests']) * 100, 2);
}
public function bandwidthBytes(): int
{
if (! $this->overview) {
return 0;
}
return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
}
protected function trafficClient(Server $server): SentinelTrafficClient
{
return app(SentinelTrafficClient::class, ['server' => $server]);
}
/**
* Resolve an app uuid to its display name, primary domain, and analytics-page link,
* memoized per request. Returns the uuid as the name for apps not owned by the team
* so a Sentinel-reported uuid never discloses another team's application name.
*
* @return array{name: string, domain: ?string, link: ?string}
*/
protected function appMeta(string $uuid): array
{
if (isset($this->appMetaCache[$uuid])) {
return $this->appMetaCache[$uuid];
}
$app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($uuid)->first();
$domain = null;
if ($app) {
$first = collect($app->fqdns)->first();
$domain = $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null;
}
$link = null;
if ($app && data_get($app, 'environment.project.uuid')) {
$link = route('project.application.analytics', [
'project_uuid' => $app->environment->project->uuid,
'environment_uuid' => $app->environment->uuid,
'application_uuid' => $app->uuid,
]);
}
return $this->appMetaCache[$uuid] = [
'name' => $app?->name ?? $uuid,
'domain' => $domain,
'link' => $link,
];
}
/**
* @return array{0: string, 1: string}
*/
private function window(): array
{
$to = now();
$from = match ($this->range) {
'7d' => now()->subDays(7),
'30d' => now()->subDays(30),
default => now()->subDay(),
};
return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
}
public function placeholder(array $params = []): View
{
$scopedServerUuid = $params['scopedServerUuid'] ?? null;
$hideSkeleton = false;
if (is_string($scopedServerUuid)) {
$server = Server::ownedByCurrentTeamCached()->firstWhere('uuid', $scopedServerUuid);
$hideSkeleton = $server !== null && ! $server->isTrafficAnalyticsEnabled();
}
// Rendered instantly; the Sentinel round-trips run in the deferred lazy-load request.
return view('livewire.analytics-placeholder', compact('hideSkeleton'));
}
public function render()
{
return view('livewire.analytics');
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Livewire\Concerns;
/**
* Shared derivations for the traffic chart payload: per-bucket sparkline series,
* the device-donut labels/series, and the globe's per-country marker data. Consumed
* by both the global and per-application analytics components, which expose
* `$series` (status buckets) and `$breakdowns` (dimension rows).
*/
trait BuildsTrafficChartPayload
{
/**
* Per-bucket total requests, for the Requests spark. Derived from the status-class
* counts (every request carries a status class), so it always matches real traffic
* regardless of whether Sentinel populates the explicit per-bucket `requests` field.
*
* @return array<int, int>
*/
public function requestsSpark(): array
{
return array_map(
fn ($b) => (int) ($b['s2xx'] ?? 0) + (int) ($b['s3xx'] ?? 0) + (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0),
$this->series,
);
}
/**
* Whether there is plottable request-over-time data for the Requests chart. False when
* Sentinel returned no series buckets (older builds) or every bucket is empty (no traffic
* in the range), so the views can render a no-data state instead of a blank chart.
*/
public function hasRequestSeries(): bool
{
return array_sum($this->requestsSpark()) > 0;
}
/**
* Per-bucket error requests (4xx + 5xx), for the Error-rate spark.
*
* @return array<int, int>
*/
public function errorsSpark(): array
{
return array_map(
fn ($b) => (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0),
$this->series,
);
}
/**
* Per-bucket bandwidth (bytes in + out), for the Bandwidth spark. Empty for
* older Sentinel builds that don't emit per-bucket byte counts.
*
* @return array<int, int>
*/
public function bandwidthSpark(): array
{
return array_map(
fn ($b) => (int) ($b['bytesIn'] ?? 0) + (int) ($b['bytesOut'] ?? 0),
$this->series,
);
}
/**
* Per-bucket unique visitors, for the Visitors spark.
*
* @return array<int, int>
*/
public function uniquesSpark(): array
{
return array_map(fn ($b) => (int) ($b['uniqueVisitors'] ?? 0), $this->series);
}
/**
* Per-bucket p95 latency (ms), for the Latency spark.
*
* @return array<int, float>
*/
public function latencySpark(): array
{
return array_map(fn ($b) => round((float) ($b['p95'] ?? 0), 1), $this->series);
}
/**
* Per-country marker data for the globe: [{code, requests}] over known ISO-A2 rows.
*
* @return array<int, array{code: string, requests: int}>
*/
protected function geoMarkers(): array
{
$out = [];
foreach (($this->breakdowns['country'] ?? []) as $row) {
$code = strtoupper((string) ($row['value'] ?? ''));
$requests = (int) ($row['requests'] ?? 0);
if (preg_match('/^[A-Z]{2}$/', $code) && $requests > 0) {
$out[] = ['code' => $code, 'requests' => $requests];
}
}
return $out;
}
/**
* Device-donut data. Raw Sentinel device values are folded into friendly labels
* (pc → Desktop, smartphone → Mobile, …) and summed, then sorted by volume.
*
* @return array{labels: array<int, string>, series: array<int, int>}
*/
public function deviceChartData(): array
{
$totals = [];
foreach (($this->breakdowns['device'] ?? []) as $row) {
$value = (string) ($row['value'] ?? '');
$label = $value === '__other__' ? 'Other' : deviceLabel($value);
$totals[$label] = ($totals[$label] ?? 0) + (int) ($row['requests'] ?? 0);
}
arsort($totals);
return [
'labels' => array_keys($totals),
'series' => array_map('intval', array_values($totals)),
];
}
}
@@ -0,0 +1,261 @@
<?php
namespace App\Livewire\Concerns;
use App\Exceptions\DnsRecordConflictException;
use App\Jobs\ConfigureDnsRecordJob;
use App\Models\DnsProviderZone;
use App\Models\ManagedDnsRecord;
use App\Services\Dns\CloudflareDnsProvider;
use Illuminate\Database\Eloquent\Model;
trait InteractsWithDnsProviders
{
public bool $showDnsProviderModal = false;
public array $dnsProviderProposals = [];
public array $dnsProviderConflicts = [];
public bool $deleteManagedDns = true;
public function openDnsProviderModal(): void
{
$this->authorizeDnsProviderChange();
$this->loadDnsProviderProposals();
if ($this->dnsProviderProposals === []) {
$this->dispatch('error', 'No connected DNS provider can manage the configured domains.');
return;
}
$this->showDnsProviderModal = true;
}
public function closeDnsProviderModal(): void
{
$this->showDnsProviderModal = false;
}
public function createManagedDnsRecord(string $hostname, int $zoneId, ?string $content = null): void
{
$this->authorizeDnsProviderChange();
$cloudflare = app(CloudflareDnsProvider::class);
$zone = $this->findTeamZone($zoneId);
$content ??= $this->serverIp;
if ($zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) {
$this->dispatch('error', 'No connected DNS provider or public server IP is available for this domain.');
return;
}
try {
$cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname));
$this->markDnsManaged($hostname, $zone->integrationToken->name);
$this->dispatch('success', "DNS record created for {$hostname}.");
$this->loadDnsProviderProposals();
} catch (DnsRecordConflictException $e) {
$this->dnsProviderConflicts[$hostname.'|'.$zoneId] = [
'record_id' => $e->providerRecordId, 'current' => $e->currentValue, 'proposed' => $e->proposedValue,
];
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
}
}
/** @param array<int, string> $urls */
protected function hasDnsProviderForUrls(array $urls): bool
{
$provider = app(CloudflareDnsProvider::class);
return collect($urls)->contains(function (string $url) use ($provider): bool {
$hostname = parse_url($url, PHP_URL_HOST);
return is_string($hostname) && $provider->findZones(currentTeam()->id, $hostname)->isNotEmpty();
});
}
/** @param array<int, string> $urls */
protected function configureDnsAfterDomainAdd(array $urls): bool
{
$hostnames = collect($urls)->map(fn (string $url) => parse_url($url, PHP_URL_HOST))
->filter(fn ($hostname) => is_string($hostname))->map(fn (string $hostname) => strtolower($hostname))
->unique()->values()->all();
$this->loadDnsProviderProposals($hostnames);
if ($this->dnsProviderProposals === []) {
return false;
}
if (blank($this->serverIp) || filter_var($this->serverIp, FILTER_VALIDATE_IP) === false) {
return false;
}
$this->markDnsPending($hostnames);
$proposalsByHostname = collect($this->dnsProviderProposals)->groupBy('hostname');
$canConfigureAutomatically = $proposalsByHostname->every(function ($proposals): bool {
if ($proposals->count() !== 1) {
return false;
}
$zone = $this->findTeamZone((int) $proposals->first()['zone_id']);
return $zone?->integrationToken->automaticDnsEnabled() === true;
});
if (! $canConfigureAutomatically) {
$this->showDnsProviderModal = true;
return true;
}
foreach ($this->dnsProviderProposals as $proposal) {
$zone = $this->findTeamZone((int) $proposal['zone_id']);
if ($zone === null) {
continue;
}
$resource = $this->dnsResourceForHostname($proposal['hostname']);
ConfigureDnsRecordJob::dispatch(
currentTeam()->id,
$zone->id,
$resource?->getMorphClass(),
$resource?->getKey(),
$proposal['hostname'],
$this->serverIp,
);
$this->dispatch('info', "Adding DNS record for {$proposal['hostname']}.");
}
return true;
}
public function openManualDnsRecords(): void
{
$this->authorizeDnsProviderChange();
$this->loadDnsProviderProposals();
$this->dispatch('open-dns-records-modal');
}
public function replaceManagedDnsRecord(string $hostname, int $zoneId, string $password = ''): void
{
$this->authorizeDnsProviderChange();
$key = $hostname.'|'.$zoneId;
$conflict = $this->dnsProviderConflicts[$key] ?? null;
$zone = $this->findTeamZone($zoneId);
$content = $this->serverIp;
if ($conflict === null || $zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) {
$this->dispatch('error', 'The DNS conflict is no longer available. Check the record again.');
return;
}
try {
app(CloudflareDnsProvider::class)->replaceRecord(
$zone,
(string) ($conflict['record_id'] ?? ''),
$hostname,
$content,
$this->dnsResourceForHostname($hostname),
(string) ($conflict['current'] ?? ''),
);
unset($this->dnsProviderConflicts[$key]);
$this->dispatch('success', "DNS record replaced for {$hostname}.");
$this->loadDnsProviderProposals();
} catch (\Throwable $e) {
unset($this->dnsProviderConflicts[$key]);
$this->dispatch('error', $e->getMessage());
}
}
protected function loadDnsProviderProposals(?array $hostnames = null): void
{
$provider = app(CloudflareDnsProvider::class);
$hostnames ??= $this->allDomainHostnames();
$managed = ManagedDnsRecord::query()->where('team_id', currentTeam()->id)->whereIn('name', $hostnames)->pluck('id', 'name');
$this->dnsProviderProposals = collect($hostnames)->flatMap(fn (string $hostname) => $provider->findZones(currentTeam()->id, $hostname)
->map(fn (DnsProviderZone $zone) => [
'hostname' => $hostname, 'zone_id' => $zone->id, 'zone' => $zone->name,
'credential' => $zone->integrationToken->name, 'target' => (string) $this->serverIp,
'managed' => $managed->has($hostname),
])->all())->values()->all();
}
protected function markDnsPending(array $hostnames): void
{
foreach ($this->domainRows as $index => $row) {
$hostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
if (is_string($hostname) && in_array(strtolower($hostname), $hostnames, true)) {
$this->domainRows[$index]['dns_status'] = 'pending';
$this->domainRows[$index]['dns_message'] = 'A connected DNS provider can create this record.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
$this->persistDomainDnsStatuses();
}
protected function markDnsManaged(string $hostname, string $credential): void
{
foreach ($this->domainRows as $index => $row) {
$rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
$this->domainRows[$index]['dns_status'] = 'ok';
$this->domainRows[$index]['dns_message'] = "DNS record created through {$credential}.";
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
$this->persistDomainDnsStatuses();
}
public function dnsRecordConfigurationFinished(array $event): void
{
$resource = $this->dnsResourceForHostname($event['hostname']);
if ($resource === null || $resource->getMorphClass() !== $event['resourceType']
|| (string) $resource->getKey() !== (string) $event['resourceId']) {
return;
}
if ($event['successful']) {
$this->markDnsManaged($event['hostname'], $event['credential']);
$this->dispatch('success', $event['message']);
return;
}
$this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}");
}
protected function deleteManagedDnsForUrl(string $url): void
{
$hostname = parse_url($url, PHP_URL_HOST);
if (! is_string($hostname)) {
return;
}
$resource = $this->dnsResourceForHostname($hostname);
if ($resource === null) {
return;
}
$record = ManagedDnsRecord::query()
->where('team_id', currentTeam()->id)
->where('name', strtolower($hostname))
->where('resource_type', $resource->getMorphClass())
->where('resource_id', $resource->getKey())
->first();
if ($record !== null && ! app(CloudflareDnsProvider::class)->deleteRecord($record)) {
$this->dispatch('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.');
}
}
protected function authorizeDnsProviderChange(): void
{
$this->authorize('update', property_exists($this, 'application') ? $this->application : $this->service);
}
protected function findTeamZone(int $zoneId): ?DnsProviderZone
{
return DnsProviderZone::query()->whereKey($zoneId)
->whereHas('integrationToken', fn ($query) => $query->where('team_id', currentTeam()->id))->first();
}
abstract protected function persistDomainDnsStatuses(): void;
abstract protected function dnsResourceForHostname(string $hostname): ?Model;
}
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace App\Livewire\Dashboard;
use App\Livewire\Concerns\BuildsTrafficChartPayload;
use App\Models\Server;
use App\Services\SentinelTrafficClient;
use App\Services\TrafficAnalyticsAggregator;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Livewire\Attributes\Lazy;
use Livewire\Component;
#[Lazy]
class TrafficAnalytics extends Component
{
use BuildsTrafficChartPayload;
public string $chartId = 'dashboard-traffic';
public Collection $servers;
public string $range = '24h';
public ?array $overview = null;
public bool $latencyApproximate = false;
public bool $uniquesApproximate = false;
/**
* Per-bucket status-class series, summed across servers; feeds the KPI sparklines.
*
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
*/
public array $series = [];
public function mount(): void
{
$this->servers = Server::ownedByCurrentTeamCached()
->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled())
->values();
if ($this->servers->isNotEmpty()) {
$this->loadData();
}
}
public function setRange(string $range): void
{
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
$this->loadData();
}
public function loadData(): void
{
if ($this->servers->isEmpty()) {
return;
}
[$from, $to] = $this->window();
$overviews = [];
$seriesByBucket = [];
foreach ($this->servers as $server) {
try {
$client = $this->trafficClient($server);
$overviews[] = $client->overview(null, $from, $to);
// Per-bucket status series, summed across servers, for the sparklines.
// Isolated so a series hiccup (older Sentinel) never drops a server's overview.
try {
foreach ($client->series(null, $this->range) as $bucket) {
$data = $bucket->toArray();
$ts = (int) ($data['bucket'] ?? 0);
$seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0];
$seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0);
$seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0);
$seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0);
$seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0);
$seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0);
$seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0);
$seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
$seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0);
$seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0));
}
} catch (\Throwable $e) {
// Leave this server out of the sparkline series.
\Log::debug('Traffic series fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]);
}
} catch (\Throwable $e) {
// Skip unreachable/failed servers so one bad server doesn't break the whole summary.
\Log::debug('Traffic overview fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]);
continue;
}
}
if (empty($overviews)) {
// Every server's fetch failed; don't present an all-zero KPI panel as if it were real data.
$this->overview = null;
$this->latencyApproximate = false;
$this->uniquesApproximate = false;
$this->series = [];
return;
}
$result = TrafficAnalyticsAggregator::sumOverviews($overviews);
$this->overview = $result['overview']->toArray();
$this->latencyApproximate = $result['latencyApproximate'];
$this->uniquesApproximate = $result['uniquesApproximate'];
ksort($seriesByBucket);
$this->series = array_values($seriesByBucket);
$this->dispatch("refreshChartData-{$this->chartId}-status", [
'requestsSpark' => $this->requestsSpark(),
'sparkCategories' => array_column($this->series, 'bucket'),
'errorsSpark' => $this->errorsSpark(),
'bandwidthSpark' => $this->bandwidthSpark(),
'uniquesSpark' => $this->uniquesSpark(),
]);
}
public function errorRate(): float
{
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
return 0.0;
}
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
return round(($errors / $this->overview['requests']) * 100, 2);
}
public function bandwidthBytes(): int
{
if (! $this->overview) {
return 0;
}
return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
}
protected function trafficClient(Server $server): SentinelTrafficClient
{
return app(SentinelTrafficClient::class, ['server' => $server]);
}
/**
* @return array{0: string, 1: string}
*/
private function window(): array
{
$to = now();
$from = match ($this->range) {
'7d' => now()->subDays(7),
'30d' => now()->subDays(30),
default => now()->subDay(),
};
return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
}
public function placeholder(): View
{
// Rendered instantly on the dashboard; Sentinel round-trips run in the deferred request.
return view('livewire.dashboard.traffic-analytics-placeholder');
}
public function render()
{
return view('livewire.dashboard.traffic-analytics');
}
}
+24
View File
@@ -170,6 +170,30 @@ class Discord extends Component
}
}
public function toggleDiscordEnabled(): void
{
try {
$this->resetErrorBag();
if ($this->discordEnabled) {
$this->discordEnabled = false;
} else {
$this->validate([
'discordWebhookUrl' => 'required',
], [
'discordWebhookUrl.required' => 'Discord Webhook URL is required.',
]);
$this->discordEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
handleError($e, $this);
}
}
public function instantSave()
{
try {
+87 -31
View File
@@ -258,32 +258,59 @@ class Email extends Component
}
}
public function toggleSmtp()
{
try {
$this->resetErrorBag();
if ($this->smtpEnabled) {
$this->smtpEnabled = false;
$this->saveModel();
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
$this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function toggleResend()
{
try {
$this->resetErrorBag();
if ($this->resendEnabled) {
$this->resendEnabled = false;
$this->saveModel();
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
$this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function submitSmtp()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
@@ -315,17 +342,7 @@ class Email extends Component
try {
$this->resetErrorBag();
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|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.',
]);
$this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
@@ -342,6 +359,45 @@ class Email extends Component
}
}
private function validateSmtpSettings(): void
{
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
private function validateResendSettings(): void
{
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|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.',
]);
}
public function sendTestEmail()
{
try {
+28
View File
@@ -163,6 +163,34 @@ class Pushover extends Component
}
}
public function togglePushoverEnabled()
{
try {
$this->resetErrorBag();
if ($this->pushoverEnabled) {
$this->pushoverEnabled = false;
} else {
$this->validate([
'pushoverUserKey' => 'required',
'pushoverApiToken' => 'required',
], [
'pushoverUserKey.required' => 'Pushover User Key is required.',
'pushoverApiToken.required' => 'Pushover API Token is required.',
]);
$this->pushoverEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function instantSave()
{
try {
+26
View File
@@ -154,6 +154,32 @@ class Slack extends Component
}
}
public function toggleSlackEnabled()
{
try {
$this->resetErrorBag();
if ($this->slackEnabled) {
$this->slackEnabled = false;
} else {
$this->validate([
'slackWebhookUrl' => 'required',
], [
'slackWebhookUrl.required' => 'Slack Webhook URL is required.',
]);
$this->slackEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function instantSave()
{
try {
+28
View File
@@ -263,6 +263,34 @@ class Telegram extends Component
}
}
public function toggleTelegramEnabled(): void
{
try {
$this->resetErrorBag();
if ($this->telegramEnabled) {
$this->telegramEnabled = false;
} else {
$this->validate([
'telegramToken' => 'required',
'telegramChatId' => 'required',
], [
'telegramToken.required' => 'Telegram Token is required.',
'telegramChatId.required' => 'Telegram Chat ID is required.',
]);
$this->telegramEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function saveModel()
{
$this->authorize('update', $this->settings);
+24
View File
@@ -148,6 +148,30 @@ class Webhook extends Component
}
}
public function toggleWebhookEnabled()
{
try {
$this->resetErrorBag();
if ($this->webhookEnabled) {
$this->webhookEnabled = false;
} else {
$this->validate([
'webhookUrl' => 'required',
], [
'webhookUrl.required' => 'Webhook URL is required.',
]);
$this->webhookEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function instantSave()
{
try {
+53 -6
View File
@@ -2,19 +2,15 @@
namespace App\Livewire\Profile;
use App\Services\AvatarStorageService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class Index extends Component
{
use WithFileUploads;
public int $userId;
public string $email;
@@ -36,6 +32,10 @@ class Index extends Component
public bool $show_verification = false;
public bool $uses_sso = false;
public ?string $sso_provider_label = null;
public $avatar;
public function uploadAvatar(AvatarStorageService $avatarStorage): bool
@@ -75,8 +75,12 @@ class Index extends Component
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
$oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first();
$this->uses_sso = $oauthIdentity !== null;
$this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null;
// Check if there's a pending email change
if (Auth::user()->hasEmailChangeRequest()) {
if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) {
$this->new_email = Auth::user()->pending_email;
$this->show_verification = true;
}
@@ -101,6 +105,10 @@ class Index extends Component
public function requestEmailChange()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
// For self-hosted, check if email is enabled
if (! isCloud()) {
$settings = instanceSettings();
@@ -159,6 +167,10 @@ class Index extends Component
public function verifyEmailChange()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
$this->validate([
'email_verification_code' => ['required', 'string', 'size:6'],
]);
@@ -204,7 +216,6 @@ class Index extends Component
$this->show_verification = false;
$this->dispatch('success', 'Email address updated successfully.');
$this->dispatch('close-email-change-modal');
} else {
$this->dispatch('error', 'Failed to update email address.');
}
@@ -216,6 +227,10 @@ class Index extends Component
public function resendVerificationCode()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
// Check if there's a pending request
if (! Auth::user()->hasEmailChangeRequest()) {
$this->dispatch('error', 'No pending email change request.');
@@ -269,6 +284,30 @@ class Index extends Component
$this->dispatch('success', 'Email change request cancelled.');
}
public function showEmailChangeForm()
{
if ($this->rejectSsoEmailChange()) {
return;
}
$this->show_email_change = true;
$this->new_email = '';
}
private function rejectSsoEmailChange(): bool
{
if (! Auth::user()->hasSsoIdentity()) {
return false;
}
$this->uses_sso = true;
$this->show_email_change = false;
$this->show_verification = false;
$this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.');
return true;
}
public function resetPassword()
{
try {
@@ -299,6 +338,14 @@ class Index extends Component
}
}
private function providerLabel(string $provider): string
{
return match ($provider) {
'oidc' => 'OIDC',
default => str($provider)->headline()->toString(),
};
}
public function render()
{
return view('livewire.profile.index');
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Models\Application;
use App\Models\ApplicationSetting;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
@@ -69,6 +70,9 @@ class Advanced extends Component
#[Validate(['string', 'nullable'])]
public ?string $customInternalName = null;
#[Validate(['string', 'nullable', 'max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH])]
public ?string $customContainerNamePrefix = null;
#[Validate(['boolean'])]
public bool $isGzipEnabled = true;
@@ -111,6 +115,7 @@ class Advanced extends Component
$this->application->settings->is_build_server_enabled = $this->isBuildServerEnabled;
$this->application->settings->is_consistent_container_name_enabled = $this->isConsistentContainerNameEnabled;
$this->application->settings->custom_internal_name = $this->customInternalName;
$this->application->settings->custom_container_name_prefix = $this->customContainerNamePrefix;
$this->application->settings->is_gzip_enabled = $this->isGzipEnabled;
$this->application->settings->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
@@ -137,6 +142,7 @@ class Advanced extends Component
$this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
$this->isConsistentContainerNameEnabled = $this->application->settings->is_consistent_container_name_enabled;
$this->customInternalName = $this->application->settings->custom_internal_name;
$this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix;
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
$this->disableBuildCache = $this->application->settings->disable_build_cache;
@@ -258,6 +264,28 @@ class Advanced extends Component
}
}
public function saveCustomNamePrefix()
{
try {
$this->authorize('update', $this->application);
$this->customContainerNamePrefix = str($this->customContainerNamePrefix)->slug()->value() ?: null;
if ($this->customContainerNamePrefix && ApplicationSetting::isContainerNamePrefixInUse($this->customContainerNamePrefix, $this->application->destination->server, $this->application->id)) {
$this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix;
$this->dispatch('error', 'This container name prefix is already in use by another application on this Coolify instance.');
return;
}
$this->syncData(true);
$this->dispatch('success', 'Container name prefix saved.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function saveStopGracePeriod()
{
try {
@@ -0,0 +1,243 @@
<?php
namespace App\Livewire\Project\Application;
use App\Livewire\Concerns\BuildsTrafficChartPayload;
use App\Models\Application;
use App\Services\SentinelTrafficClient;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Lazy;
use Livewire\Component;
#[Lazy]
class Analytics extends Component
{
use BuildsTrafficChartPayload;
public Application $application;
public string $chartId = 'application-analytics';
public string $range = '24h';
public bool $enabled = false;
// Realtime refresh. Off by default (click "Live" to arm it). Only meaningful on the
// 24h range; the 60s cadence matches the SentinelTrafficClient cache TTL and
// Sentinel's per-minute rollups. The control is disabled for 7d/30d.
public bool $live = false;
public ?array $overview = null;
public array $topPaths = [];
/** @var array<string, array<int, array<string, mixed>>> */
public array $breakdowns = [];
public ?string $attribution = null;
/**
* Per-bucket status-class time series for the stacked area chart. Empty when this
* app's Sentinel lacks the series endpoint, which flips the chart to the donut.
*
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
*/
public array $series = [];
public bool $hasSeries = false;
/** @var array<int, string> */
protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent'];
public function mount(): void
{
$this->enabled = (bool) $this->application->destination?->server?->isTrafficAnalyticsEnabled();
if ($this->enabled) {
$this->loadData();
}
}
public function setRange(string $range): void
{
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
$this->loadData();
}
public function toggleLive(): void
{
if ($this->range !== '24h') {
return;
}
$this->live = ! $this->live;
}
/**
* Realtime polling is only armed when the user has it on and the range is 24h.
*/
public function isLivePollable(): bool
{
return $this->live && $this->range === '24h';
}
public function loadData(): void
{
if (! $this->enabled) {
return;
}
try {
[$from, $to] = $this->window();
$client = $this->trafficClient();
$key = $this->application->uuid;
// Warm every endpoint for this app in one docker exec instead of ~14 serial
// SSH round-trips; the per-call methods below then read from cache.
$client->prefetchServerWide($key, $from, $to, $this->breakdownDimensions, $this->range);
$this->overview = $client->overview($key, $from, $to)->toArray();
// Every path belongs to this one app, so decorate each row with its domain
// for a consistent "domain + path" presentation and an openable live link.
$domain = $this->applicationDomain();
$this->topPaths = $client->paths($key, $from, $to, 50)
->map(fn ($path) => ['domain' => $domain] + $path->toArray())
->all();
$breakdowns = [];
foreach ($this->breakdownDimensions as $dimension) {
$breakdowns[$dimension] = $client->breakdown($key, $dimension, $from, $to, 50)
->map(fn ($row) => $row->toArray())
->all();
}
$this->breakdowns = $breakdowns;
$this->attribution = $client->attribution();
// Per-bucket status series; absent on older Sentinel builds (empty → donut fallback).
// Isolated so a series hiccup never errors the rest of the widget.
try {
$this->series = $client->series($key, $this->range)
->map(fn ($bucket) => $bucket->toArray())
->all();
} catch (\Throwable $e) {
$this->series = [];
}
$this->hasSeries = $this->series !== [];
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Payload for the status chart: the stacked-area time series when available,
* plus the donut totals as a fallback for older Sentinel builds.
*
* @return array<string, mixed>
*/
protected function chartPayload(): array
{
$device = $this->deviceChartData();
return [
'hasSeries' => $this->hasSeries,
'range' => $this->range,
'seriesData' => [
$this->overview['s2xx'] ?? 0,
$this->overview['s3xx'] ?? 0,
$this->overview['s4xx'] ?? 0,
$this->overview['s5xx'] ?? 0,
],
'timeSeries' => [
'categories' => array_column($this->series, 'bucket'),
'requests' => $this->requestsSpark(),
's2xx' => array_column($this->series, 's2xx'),
's3xx' => array_column($this->series, 's3xx'),
's4xx' => array_column($this->series, 's4xx'),
's5xx' => array_column($this->series, 's5xx'),
],
'requestsSpark' => $this->requestsSpark(),
'sparkCategories' => array_column($this->series, 'bucket'),
'errorsSpark' => $this->errorsSpark(),
'bandwidthSpark' => $this->bandwidthSpark(),
'uniquesSpark' => $this->uniquesSpark(),
'latencySpark' => $this->latencySpark(),
'geo' => $this->geoMarkers(),
'deviceLabels' => $device['labels'],
'deviceSeries' => $device['series'],
];
}
public function errorRate(): float
{
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
return 0.0;
}
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
return round(($errors / $this->overview['requests']) * 100, 2);
}
public function bandwidthBytes(): int
{
if (! $this->overview) {
return 0;
}
return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
}
protected function trafficClient(): SentinelTrafficClient
{
return app(SentinelTrafficClient::class, ['server' => $this->application->destination->server]);
}
/**
* Primary domain host for this application (first configured FQDN), or null when
* none is set — used to present paths as "domain + path" with an openable link.
*/
protected function applicationDomain(): ?string
{
$first = collect($this->application->fqdns)->first();
return $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null;
}
/**
* @return array{0: string, 1: string}
*/
private function window(): array
{
$to = now();
$from = match ($this->range) {
'7d' => now()->subDays(7),
'30d' => now()->subDays(30),
default => now()->subDay(),
};
return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
}
public function placeholder(array $params = []): View
{
$application = $params['application'] ?? null;
if ($application instanceof Application && ! $application->destination?->server?->isTrafficAnalyticsEnabled()) {
$this->application = $application;
$this->enabled = false;
return view('livewire.project.application.analytics');
}
// Rendered instantly; the Sentinel round-trip runs in the deferred lazy-load request.
return view('livewire.project.application.analytics-placeholder');
}
public function render()
{
return view('livewire.project.application.analytics');
}
}
@@ -104,7 +104,6 @@ class DeploymentNavbar extends Component
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
try {
if ($this->application->settings->is_build_server_enabled) {
$server = Server::ownedByCurrentTeam()->find($build_server_id);
+30 -5
View File
@@ -5,12 +5,14 @@ namespace App\Livewire\Project\Application;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Concerns\InteractsWithDnsProviders;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
use App\Models\Server;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -20,6 +22,7 @@ class Domains extends Component
{
use AuthorizesRequests;
use InteractsWithCloudflareDomainConnect;
use InteractsWithDnsProviders;
protected bool $notifyRedirectUpdate = true;
@@ -127,6 +130,13 @@ class Domains extends Component
'confirmDomainUsage',
];
public function getListeners(): array
{
return array_merge($this->listeners, [
'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished',
]);
}
protected function rules(): array
{
return [
@@ -1027,8 +1037,14 @@ class Domains extends Component
$this->resetAddDomainForm();
$this->dispatch('close-modal');
$this->refreshDomains();
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
$dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
$addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
if ($this->configureDnsAfterDomainAdd($addedUrls)) {
$this->dispatch('success', 'Domain added.');
return;
}
$dnsChecks = collect($this->dnsEntriesForUrls($addedUrls, $serviceForCheck))
->map(fn (string $url, string $statusKey) => [
'status_key' => $statusKey,
'url' => $url,
@@ -1562,7 +1578,7 @@ class Domains extends Component
}
}
public function removeDomain(int $index): void
public function removeDomain(int $index, string $password = '', array $selectedActions = []): void
{
try {
$this->authorize('update', $this->application);
@@ -1585,6 +1601,10 @@ class Domains extends Component
return;
}
if (in_array('deleteManagedDns', $selectedActions, true)) {
$this->deleteManagedDnsForUrl($url);
}
if ($this->editingIndex === $index) {
$this->cancelEdit();
}
@@ -1597,7 +1617,7 @@ class Domains extends Component
}
}
public function removeDomainByKey(string $domainKey): void
public function removeDomainByKey(string $domainKey, string $password = '', array $selectedActions = []): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
@@ -1608,7 +1628,7 @@ class Domains extends Component
return;
}
$this->removeDomain((int) $index);
$this->removeDomain((int) $index, $password, $selectedActions);
}
/**
@@ -1619,6 +1639,11 @@ class Domains extends Component
return hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
}
protected function dnsResourceForHostname(string $hostname): ?Model
{
return $this->application;
}
public function generateDomain(?string $serviceName = null): void
{
try {
@@ -156,6 +156,11 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
auditLog('ui.application.stopped', [
'team_id' => $this->application->team()?->id,
'application_uuid' => $this->application->uuid,
'application_name' => $this->application->name,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -314,6 +314,12 @@ class Previews extends Component
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']);
auditLog('ui.application.preview_stopped', [
'team_id' => $this->application->team()?->id,
'application_uuid' => $this->application->uuid,
'application_name' => $this->application->name,
'pull_request_id' => $pull_request_id,
]);
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server);
@@ -0,0 +1,73 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\Application;
use App\Services\SentinelTrafficClient;
use Livewire\Attributes\Lazy;
use Livewire\Component;
/**
* Compact last-24h traffic KPI card for the application General page. Lazy-loaded so
* the General page isn't blocked by Sentinel's docker-exec round-trip.
*/
#[Lazy]
class TrafficOverview extends Component
{
public Application $application;
public bool $enabled = false;
public bool $eligible = false;
public ?string $serverUuid = null;
public ?array $overview = null;
public function mount(): void
{
// Runs in the deferred lazy-load request, so the Sentinel fetch never blocks
// the initial General-page render.
$server = $this->application->destination?->server;
$this->serverUuid = $server?->uuid;
$this->enabled = (bool) $server?->isTrafficAnalyticsEnabled();
$this->eligible = $server ? (! $server->isSwarm() && ! $server->isBuildServer()) : false;
if ($this->enabled && $server) {
try {
$client = app(SentinelTrafficClient::class, ['server' => $server]);
$this->overview = $client->appOverview($this->application->uuid, '24h')->toArray();
} catch (\Throwable $e) {
$this->overview = null;
}
}
}
public function hasData(): bool
{
return $this->overview !== null && (int) ($this->overview['requests'] ?? 0) > 0;
}
public function errorRate(): float
{
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
return 0.0;
}
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
return round(($errors / $this->overview['requests']) * 100, 2);
}
public function placeholder(): string
{
return <<<'HTML'
<div class="h-24 w-full animate-pulse rounded-xl border border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.02]"></div>
HTML;
}
public function render()
{
return view('livewire.project.application.traffic-overview');
}
}
+8
View File
@@ -102,6 +102,14 @@ class CloneMe extends Component
if (! $selectedDestination) {
throw new \Exception('Destination not found.');
}
auditLog('ui.project.clone_started', [
'team_id' => $this->project->team_id,
'project_uuid' => $this->project->uuid,
'project_name' => $this->project->name,
'clone_type' => $type,
'new_name' => $this->newName,
'destination_uuid' => $selectedDestination->uuid,
]);
if ($type === 'project') {
$foundProject = Project::where('name', $this->newName)->first();
if ($foundProject) {
+17 -4
View File
@@ -212,10 +212,18 @@ class BackupEdit extends Component
}
}
$database = $this->backup->database;
$backupUuid = $this->backup->uuid;
$this->backup->delete();
auditLog('ui.database.backup_schedule_deleted', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $backupUuid,
]);
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database;
if ($database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $database;
return redirectRoute($this, 'project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'],
@@ -251,9 +259,14 @@ class BackupEdit extends Component
}
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
$database = $this->backup->database;
auditLog('ui.database.backup_started', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
if ($database instanceof ServiceDatabase) {
return redirect()->route('project.service.database.backup.executions', [
@@ -25,6 +25,13 @@ class BackupNow extends Component
}
DatabaseBackupJob::dispatch($this->backup);
$database = $this->backup->database;
auditLog('ui.database.backup_started', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);
+12
View File
@@ -89,6 +89,7 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping database.');
StopDatabase::dispatch($this->database, false, $this->docker_cleanup);
$this->auditDatabaseAction('ui.database.stopped');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
}
@@ -100,6 +101,7 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -113,6 +115,7 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.started');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -128,4 +131,13 @@ class Heading extends Component
],
]);
}
private function auditDatabaseAction(string $event): void
{
auditLog($event, [
'team_id' => $this->database->team()?->id,
'database_uuid' => $this->database->uuid,
'database_name' => $this->database->name,
]);
}
}
+51 -320
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Database;
use App\Actions\Database\StartDatabaseImport;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
@@ -10,12 +11,13 @@ use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseBackupFileValidator;
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
use App\Support\DatabaseImport\DatabaseImportException;
use App\Support\DatabaseImport\DatabaseImportSource;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
@@ -158,13 +160,15 @@ class ImportForm extends Component
public bool $dumpAll = false;
public bool $replaceExisting = false;
public string $restoreCommandText = '';
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $postgresqlRestoreCommand = 'pg_restore --exit-on-error -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
@@ -276,13 +280,24 @@ createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
$this->syncPostgresqlRestoreCommand();
}
break;
}
}
public function updatedReplaceExisting(): void
{
$this->syncPostgresqlRestoreCommand();
}
private function syncPostgresqlRestoreCommand(): void
{
$replaceExisting = $this->replaceExisting ? ' --clean --if-exists' : '';
$this->postgresqlRestoreCommand = 'pg_restore --exit-on-error'.$replaceExisting.' -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
}
public function getContainers()
{
$this->containers = [];
@@ -446,72 +461,25 @@ EOD;
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
Storage::delete($backupFileName);
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
return true;
}
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return true;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
if (! empty($this->importCommands)) {
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
'scriptPath' => $scriptPath,
'tmpPath' => $tmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
$source = Storage::exists("upload/{$this->resourceUuid}/restore")
? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting)
: new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$this->activityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.import_started', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
'source' => 'file',
'replace_existing' => $this->replaceExisting,
]);
} catch (DatabaseImportException $e) {
$this->importRunning = false;
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
return true;
@@ -654,121 +622,23 @@ EOD;
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return true;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return true;
}
// Get helper image
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
$escapedScriptPath = escapeshellarg($scriptPath);
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
$commands[] = "chmod +x {$escapedScriptPath}";
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.restore_started', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
'source' => 's3',
'replace_existing' => $this->replaceExisting,
'storage_id' => $this->s3StorageId,
]);
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (DatabaseImportException $e) {
$this->importRunning = false;
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
@@ -779,147 +649,8 @@ EOD;
return true;
}
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
{
$script = $this->buildPostgresRestoreScanScript($tmpPath);
if ($script === null) {
return null;
}
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
}
/**
* Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
* backup contains directives leading to OS command execution.
*
* Hardened against bypasses:
* - decompresses gzip backups before scanning,
* - converts custom-format (PGDMP) archives to SQL with pg_restore
* before scanning, and rejects archives that cannot be inspected,
* - strips `--` line comments and flattens newlines so multi-line and
* comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught,
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
*/
public function buildPostgresRestoreScanScript(string $tmpPath): ?string
{
if (! $this->isPostgresqlRestore()) {
return null;
}
$escapedTmpPath = escapeshellarg($tmpPath);
// Token separator PostgreSQL treats as whitespace: real whitespace or a
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
$sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program";
$psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)";
$escapedSqlPattern = escapeshellarg($sqlPattern);
$escapedPsqlPattern = escapeshellarg($psqlPattern);
$contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }";
$scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}";
$customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null');
$sqlScan = $scan($contents);
$blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1';
$blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1';
return <<<SH
header=\$({$contents} | head -c 5)
if [ "\$header" = 'PGDMP' ]; then
inspect=\$(mktemp)
trap 'rm -f "\$inspect"' EXIT
if ! {$contents} > "\$inspect"; then
{$blockedInspect}
fi
if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then
{$blockedInspect}
fi
if {$customScan}; then
{$blockedProgram}
fi
elif {$sqlScan}; then
{$blockedProgram}
fi
SH;
}
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
{
$command = $this->buildRestoreSafetyCheckCommand($tmpPath);
if ($command !== null) {
$commands[] = $command;
}
}
private function isPostgresqlRestore(): bool
{
$morphClass = $this->resource->getMorphClass();
if ($morphClass === ServiceDatabase::class) {
return str_contains($this->resource->databaseType(), 'postgres');
}
return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
}
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi";
} else {
$restoreCommand .= " {$escapedTmpPath}";
}
break;
case StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll, $this->replaceExisting);
}
}
+42 -5
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Concerns\InteractsWithDnsProviders;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Server;
use App\Models\Service;
@@ -12,6 +13,7 @@ use App\Models\ServiceApplication;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -21,6 +23,7 @@ class Domains extends Component
{
use AuthorizesRequests;
use InteractsWithCloudflareDomainConnect;
use InteractsWithDnsProviders;
protected bool $notifyRedirectUpdate = true;
@@ -118,6 +121,13 @@ class Domains extends Component
'confirmDomainUsage',
];
public function getListeners(): array
{
return array_merge($this->listeners, [
'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished',
]);
}
protected function rules(): array
{
return [
@@ -562,6 +572,11 @@ class Domains extends Component
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
}
protected function persistDomainDnsStatuses(): void
{
$this->persistAllDomainDnsStatuses();
}
protected function persistAllDomainDnsStatuses(): void
{
$byApp = [];
@@ -1065,9 +1080,15 @@ class Domains extends Component
$this->pendingAction = null;
$this->dispatch('close-modal');
$this->refreshDomains();
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
$addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
if ($this->configureDnsAfterDomainAdd($addedUrls)) {
$this->dispatch('success', 'Domain added.');
return;
}
$serviceApplicationId = (int) $app->id;
$dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
$dnsChecks = collect($addedUrls)->map(fn (string $url) => [
'url' => $url,
'check_id' => new_public_id(),
]);
@@ -1313,7 +1334,7 @@ class Domains extends Component
}
}
public function removeDomain(int $index): void
public function removeDomain(int $index, string $password = '', array $selectedActions = []): void
{
try {
$this->authorize('update', $this->service);
@@ -1336,6 +1357,10 @@ class Domains extends Component
return;
}
if (in_array('deleteManagedDns', $selectedActions, true)) {
$this->deleteManagedDnsForUrl($url);
}
$this->forceSaveDomains = false;
$this->forceRemovePort = false;
$this->dispatch('success', 'Domain removed.');
@@ -1346,7 +1371,7 @@ class Domains extends Component
}
}
public function removeDomainByKey(string $domainKey): void
public function removeDomainByKey(string $domainKey, string $password = '', array $selectedActions = []): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
@@ -1357,7 +1382,7 @@ class Domains extends Component
return;
}
$this->removeDomain((int) $index);
$this->removeDomain((int) $index, $password, $selectedActions);
}
/**
@@ -1368,6 +1393,18 @@ class Domains extends Component
return hash('sha256', $row['url'].'|'.$row['service_application_id']);
}
protected function dnsResourceForHostname(string $hostname): ?Model
{
foreach ($this->domainRows as $row) {
$rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
return $this->findServiceApp((int) $row['service_application_id']);
}
}
return null;
}
public function addSuggestedDomain(int $index): void
{
try {
+13
View File
@@ -116,6 +116,7 @@ class Heading extends Component
try {
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->auditServiceAction('ui.service.started');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -149,6 +150,7 @@ class Heading extends Component
try {
$this->authorizeService('stop');
StopService::dispatch($this->service, false, $this->docker_cleanup);
$this->auditServiceAction('ui.service.stopped');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -165,6 +167,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -206,6 +209,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -222,6 +226,15 @@ class Heading extends Component
$this->authorize($ability, $this->service);
}
private function auditServiceAction(string $event): void
{
auditLog($event, [
'team_id' => $this->service->team()?->id,
'service_uuid' => $this->service->uuid,
'service_name' => $this->service->name,
]);
}
public function render()
{
return view('livewire.project.service.heading', [
+10 -4
View File
@@ -78,6 +78,7 @@ class Storage extends Component
$this->activeTab = $this->resolveDefaultTab();
$this->fileStorage = collect();
$this->loadFileStorageForActiveTab();
$this->name = $this->generateDefaultVolumeName();
}
public function refreshStoragesFromEvent()
@@ -208,9 +209,7 @@ class Storage extends Component
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
'host_path' => $this->isSwarm
? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
: ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
], array_merge(ValidationPatterns::volumeNameMessages(), [
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
]));
@@ -343,7 +342,7 @@ class Storage extends Component
public function clearForm()
{
$this->name = '';
$this->name = $this->generateDefaultVolumeName();
$this->mount_path = '';
$this->host_path = null;
$this->file_storage_path = '';
@@ -376,6 +375,13 @@ class Storage extends Component
throw new \Exception('No valid resource type for file mount storage type!');
}
private function generateDefaultVolumeName(): string
{
$name = str($this->resource->name)->slug()->value();
return ($name ?: 'volume').'-data';
}
public function fileStoragePreviewPath(): string
{
$path = str($this->file_storage_path)->trim();
@@ -64,6 +64,13 @@ class Destination extends Component
$this->authorize('deploy', $this->resource);
$server = Server::ownedByCurrentTeam()->findOrFail($serverId);
StopApplicationOneServer::run($this->resource, $server);
auditLog('ui.application.destination_stopped', [
'team_id' => $this->resource->team()?->id,
'application_uuid' => $this->resource->uuid,
'application_name' => $this->resource->name,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
]);
$this->refreshServers();
} catch (\Exception $e) {
return handleError($e, $this);
@@ -9,14 +9,27 @@ use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Add extends Component
{
use AuthorizesRequests, EnvironmentVariableAnalyzer;
use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete;
protected function secretManagerResource(): ?Model
{
if ($this->shared || ! $this->resource) {
return null;
}
return $this->resource;
}
public $resource;
public $parameters;
@@ -13,7 +13,9 @@ use App\Models\SharedEnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -22,7 +24,12 @@ class Show extends Component
{
public bool $showEnvironmentType = true;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete;
protected function secretManagerResource(): ?Model
{
return $this->isSharedVariable ? null : $this->env->resourceable;
}
public $parameters;
@@ -164,7 +171,24 @@ class Show extends Component
$this->valuesLoaded = true;
}
public function copyValue(): ?string
{
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
return null;
}
if (! $this->env instanceof ModelsEnvironmentVariable) {
return $this->env->value;
}
return $this->env->get_real_environment_variables_with_server(
$this->env->resolveReferencedValue(),
$this->env->resourceable,
);
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
@@ -207,7 +231,7 @@ class Show extends Component
$this->is_required = (bool) ($this->env->is_required ?? false);
// Use the stored column, not the value-based accessor (that decrypts).
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
$this->isValueHidden = auth()->user()?->isMember() ?? false;
$this->isValueHidden = auth()->user()?->isMember() ?? true;
if ($this->valuesLoaded) {
$this->hydrateValueFields();
@@ -234,12 +258,12 @@ class Show extends Component
$this->is_really_required = $this->is_required && blank($this->value);
}
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
$this->value = null;
$this->real_value = null;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
$this->isValueHidden = auth()->user()?->isMember() ?? true;
}
public function checkEnvs()
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\EnvironmentVariable;
use Livewire\Component;
class ShowHardcoded extends Component
@@ -20,6 +21,10 @@ class ShowHardcoded extends Component
public bool $isPreview = false;
public ?string $resourceableType = null;
public ?int $resourceableId = null;
public function mount()
{
$this->key = $this->env['key'];
@@ -28,6 +33,20 @@ class ShowHardcoded extends Component
$this->serviceName = $this->env['service_name'] ?? null;
}
public function copyValue(): ?string
{
if (auth()->user()?->isMember() ?? true) {
return null;
}
return EnvironmentVariable::make([
'value' => $this->value,
'is_preview' => $this->isPreview,
'resourceable_type' => $this->resourceableType,
'resourceable_id' => $this->resourceableId,
])->resolveReferencedValue();
}
public function render()
{
return view('livewire.project.shared.environment-variable.show-hardcoded');
@@ -86,6 +86,14 @@ class ResourceOperations extends Component
if (! $server->canHostResources()) {
return $this->addError('destination_id', 'The selected server cannot host resources.');
}
auditLog('ui.resource.clone_started', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'resource_type' => class_basename($this->resource),
'destination_uuid' => $new_destination->uuid,
'environment_id' => $new_environment->id,
]);
if ($this->resource->getMorphClass() === Application::class) {
$new_resource = clone_application($this->resource, $new_destination, [
@@ -184,6 +184,13 @@ class Show extends Component
$this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
ScheduledTaskJob::dispatch($this->task);
auditLog('ui.scheduled_task.executed', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'scheduled_task_uuid' => $this->task->uuid,
'scheduled_task_name' => $this->task->name,
]);
$this->dispatch('success', 'Scheduled task executed.');
} catch (\Exception $e) {
return handleError($e);
@@ -0,0 +1,290 @@
<?php
namespace App\Livewire\Project\Shared;
use App\Models\IntegrationToken;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
/**
* Manages a resource's single secret manager source and lets the user
* browse remote key names, add {{vault.KEY}} reference variables, and import
* all missing keys. Secret values never enter the component state or the DB.
*/
class SecretManagerLinks extends Component
{
use AuthorizesRequests;
public $resource;
public $link;
public $availableTokens;
public string $integration_token_uuid = '';
public array $settings = [];
/** @var list<string> Remote key names only — values are never stored. */
public array $keys = [];
public bool $keysLoaded = false;
public string $search = '';
public function mount(): void
{
$this->loadData();
}
private function loadData(): void
{
$this->link = $this->resource->secretManagerLink()->with('integrationToken')->first();
$this->availableTokens = IntegrationToken::ownedByCurrentTeam()
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
->get()
->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true))
->values();
if ($this->link) {
$this->integration_token_uuid = $this->link->integrationToken->uuid;
$this->settings = $this->link->settings ?? [];
}
}
public function getSelectedTokenProperty(): ?IntegrationToken
{
if (blank($this->integration_token_uuid)) {
return null;
}
return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid);
}
protected function rules(): array
{
$rules = [
'integration_token_uuid' => ['required', 'string'],
];
$rules += match ($this->selectedToken?->provider) {
'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account'
? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
]
: [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
return $rules;
}
/**
* Auto-save when a token is selected in the dropdown. Existing {{vault.*}}
* references are intentionally NOT re-checked — missing keys surface at
* the next deployment.
*/
public function updatedIntegrationTokenUuid(): void
{
try {
$this->authorize('update', $this->resource);
$token = $this->selectedToken;
if (! $token) {
return;
}
if ($this->link?->integrationToken?->provider !== $token->provider
|| $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) {
$this->settings = [];
}
$settings = array_filter($this->settings, fn ($value) => filled($value));
$this->resource->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
$this->auditSecretManagerAction('source_updated', [
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
]);
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Auto-save of the provider-specific settings fields (called on blur).
*/
public function saveSettings(): void
{
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
$validated = $this->validate();
try {
$settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
$this->link->update(['settings' => $settings ?: null]);
$this->auditSecretManagerAction('settings_updated');
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager settings saved.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function removeSource(): void
{
try {
$this->authorize('update', $this->resource);
$token = $this->link?->integrationToken;
$this->resource->secretManagerLink()->delete();
$this->auditSecretManagerAction('source_removed', [
'integration_token_uuid' => $token?->uuid,
'provider' => $token?->provider,
]);
$this->link = null;
$this->integration_token_uuid = '';
$this->settings = [];
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function loadKeys(): void
{
try {
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
// Values are fetched into memory, reduced to key names, and discarded.
$keys = array_keys($this->link->fetchSecrets());
sort($keys);
$this->keys = $keys;
$this->keysLoaded = true;
$this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Could not fetch keys: '.$e->getMessage());
}
}
public function addReference(string $key): void
{
try {
$this->authorize('update', $this->resource);
if (! in_array($key, $this->keys, true)) {
return;
}
if ($this->resource->environment_variables()->where('key', $key)->exists()) {
$this->dispatch('error', "A variable with the key {$key} already exists.");
return;
}
$this->resource->environment_variables()->create([
'key' => $key,
'value' => '{{vault.'.$key.'}}',
]);
$this->auditSecretManagerAction('reference_created', ['secret_key' => $key]);
$this->dispatch('refreshEnvs');
$this->dispatch('success', "Added {$key} as {{vault.{$key}}}.");
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function importAll(): void
{
try {
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
$imported = $this->link->importMissingReferences();
$this->auditSecretManagerAction('references_imported', [
'key_count' => count($imported),
'secret_keys' => $imported,
]);
$this->dispatch('refreshEnvs');
$this->dispatch('success', $imported === []
? 'All remote keys already exist as variables.'
: 'Imported '.count($imported).' keys as {{vault.KEY}} references.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
private function resetKeys(): void
{
$this->keys = [];
$this->keysLoaded = false;
$this->search = '';
}
/** @param array<string, mixed> $context */
private function auditSecretManagerAction(string $action, array $context = []): void
{
$resourceType = str(class_basename($this->resource))->snake()->value();
auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([
'team_id' => $this->resource->team()?->id,
"{$resourceType}_uuid" => $this->resource->uuid,
"{$resourceType}_name" => $this->resource->name,
], $context));
}
public function getFilteredKeysProperty(): array
{
if (blank($this->search)) {
return $this->keys;
}
return array_values(array_filter(
$this->keys,
fn (string $key) => stripos($key, $this->search) !== false,
));
}
public function render(): View
{
return view('livewire.project.shared.secret-manager-links', [
'selectedToken' => $this->selectedToken,
'filteredKeys' => $this->filteredKeys,
]);
}
}
@@ -108,6 +108,25 @@ class All extends Component
$this->submit($storageId);
}
public function clearHostPath(int $storageId): void
{
$this->authorize('update', $this->resource);
$storage = $this->findStorageOrFail($storageId);
if ($storage->shouldBeReadOnlyInUI()) {
$this->dispatch('error', 'This volume is read-only.');
return;
}
$storage->host_path = null;
$storage->save();
$this->forms[$storageId]['hostPath'] = null;
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.');
}
/**
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/
@@ -1,201 +0,0 @@
<?php
namespace App\Livewire\Project\Shared\Storages;
use App\Livewire\Project\Service\Storage as StorageComponent;
use App\Models\Application;
use App\Models\LocalPersistentVolume;
use App\Models\ScheduledVolumeBackup;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public LocalPersistentVolume $storage;
public $resource;
public bool $isReadOnly = false;
public bool $isFirst = true;
public bool $isService = false;
public ?string $startedAt = null;
public bool $supportsPreviewSuffix = false;
// Explicit properties
public string $name;
public string $mountPath;
public ?string $hostPath = null;
public bool $isPreviewSuffixEnabled = true;
public bool $hasEnabledBackup = false;
public ?string $backupUrl = null;
/**
* When true, parent already batched badge/url data — skip per-row queries on mount.
*/
public bool $backupMetaHydrated = false;
/** When true, the Backup Configure Livewire modal is mounted (lazy). */
public bool $showBackupModal = false;
protected $validationAttributes = [
'name' => 'name',
'mountPath' => 'mount',
'hostPath' => 'host',
];
protected function rules(): array
{
return [
'name' => ValidationPatterns::volumeNameRules(),
'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'isPreviewSuffixEnabled' => 'required|boolean',
];
}
protected function messages(): array
{
return array_merge(
ValidationPatterns::volumeNameMessages(),
[
'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
]
);
}
/**
* Sync data between component properties and model
*
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
*/
private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->storage->name = $this->name;
$this->storage->mount_path = $this->mountPath;
$this->storage->host_path = $this->hostPath;
$this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->storage->name;
$this->mountPath = $this->storage->mount_path;
$this->hostPath = $this->storage->host_path;
$this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true;
}
}
public function mount(): void
{
$this->syncData(false);
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
// PR deployment volume suffixes only apply to git-based applications.
$this->supportsPreviewSuffix = $this->resource instanceof Application
&& $this->resource->git_based()
&& filled($this->resource->git_repository)
&& ! $this->isService;
// Parent All batches badge/url; isolated embeds still hydrate themselves.
if (! $this->backupMetaHydrated) {
$this->refreshBackupStatus();
}
}
#[On('refreshVolumeBackups')]
public function refreshBackupStatus(): void
{
$backup = $this->storage->scheduledBackups()->first();
$this->hasEnabledBackup = $backup?->enabled ?? false;
$this->backupUrl = null;
if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
return;
}
$this->resource->loadMissing('environment.project');
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
];
$hasOtherBackups = ScheduledVolumeBackup::query()
->forApplication($this->resource)
->where('id', '!=', $backup->id)
->exists();
$this->backupUrl = $hasOtherBackups
? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name])
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
public function openBackupModal(): void
{
$this->authorize('update', $this->resource);
$this->showBackupModal = true;
}
#[On('modalClosed')]
public function onModalClosed(): void
{
// Drop the nested Create component from the DOM after close to free snapshot weight.
if ($this->showBackupModal) {
$this->showBackupModal = false;
}
}
public function instantSave(): void
{
$this->authorize('update', $this->resource);
$this->validate();
$this->syncData(true);
$this->storage->save();
$this->dispatch('success', 'Storage updated successfully');
}
public function submit()
{
$this->authorize('update', $this->resource);
$this->validate();
$this->syncData(true);
$this->storage->save();
$this->dispatch('success', 'Storage updated successfully');
}
public function delete($password, $selectedActions = [])
{
$this->authorize('update', $this->resource);
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
if ($this->storage->scheduledBackups()->exists()) {
$this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
return false;
}
$this->storage->delete();
$this->dispatch('storageCountsChanged')->to(StorageComponent::class);
$this->dispatch('configurationChanged');
return true;
}
}
@@ -208,6 +208,12 @@ class VolumeBackups extends Component
}
VolumeBackupJob::dispatch($this->backup);
auditLog('ui.volume_backup.started', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Storage backup queued.');
return redirect()->route($this->routeName('executions'), $this->routeParameters());
+11
View File
@@ -140,6 +140,12 @@ class ApiTokens extends Component
]);
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
auditLog('ui.api_token.created', [
'team_id' => currentTeam()->id,
'api_token_name' => $this->description,
'abilities' => array_values($this->permissions),
'expires_at' => $expiresAt?->toIso8601String(),
]);
$this->getTokens();
// Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens.
session()->flash('token', $token->plainTextToken);
@@ -156,7 +162,12 @@ class ApiTokens extends Component
->where('id', $id)
->firstOrFail();
$this->authorize('delete', $token);
$tokenName = $token->name;
$token->delete();
auditLog('ui.api_token.revoked', [
'team_id' => currentTeam()->id,
'api_token_name' => $tokenName,
]);
$this->getTokens();
} catch (\Exception $e) {
return handleError($e, $this);
@@ -0,0 +1,213 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class IntegrationTokenEditor extends Component
{
use AuthorizesRequests;
public IntegrationToken $integrationToken;
public string $name = '';
public string $newToken = '';
public array $capabilities = [];
public array $metadata = [];
public int $zoneCount = 0;
/** @var array<int, array{id: int, name: string, account_name: ?string, managed_records_count: int}> */
public array $zones = [];
public bool $automaticDns = true;
public function mount(string $integration_token_uuid): void
{
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
->whereUuid($integration_token_uuid)
->firstOrFail();
$this->authorize('view', $this->integrationToken);
$this->name = $this->integrationToken->name;
$this->capabilities = $this->integrationToken->capabilities;
$this->metadata = $this->integrationToken->metadata ?? [];
$this->loadZones();
$this->automaticDns = $this->integrationToken->automaticDnsEnabled();
}
protected function rules(): array
{
$allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets';
$rules = [
'name' => ['required', 'string', 'max:255'],
'newToken' => ['nullable', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:'.$allowedCapability],
'automaticDns' => ['boolean'],
];
if ($this->integrationToken->provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url'];
$rules['metadata.client_id'] = ['required', 'string'];
}
if ($this->integrationToken->provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
return $rules;
}
protected function messages(): array
{
return [
'capabilities.required' => 'Select at least one capability.',
'capabilities.min' => 'Select at least one capability.',
];
}
public function save(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
{
$this->authorize('update', $this->integrationToken);
$validated = $this->validate();
$provider = $this->integrationToken->provider;
$token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
if ($provider === 'cloudflare') {
if ($validated['automaticDns']) {
unset($metadata['automatic_dns']);
} else {
$metadata['automatic_dns'] = false;
}
}
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
$metadataChanged = $metadata != ($this->integrationToken->metadata ?? []);
try {
if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged)
&& ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) {
$this->dispatch('error', $validator->errorMessage($provider));
return;
}
$updates = [
'name' => $validated['name'],
'capabilities' => $validated['capabilities'],
'metadata' => $metadata ?: null,
];
if (filled($validated['newToken'])) {
$updates['token'] = $validated['newToken'];
}
DB::transaction(function () use ($updates, $provider, $validated, $capabilitiesChanged, $cloudflare): void {
$this->integrationToken->update($updates);
if ($provider === 'cloudflare' && (filled($validated['newToken']) || $capabilitiesChanged)) {
$cloudflare->syncZones($this->integrationToken);
}
});
$this->newToken = '';
$this->loadZones();
auditLog('ui.integration_token.updated', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $this->integrationToken->uuid,
'integration_token_name' => $this->integrationToken->name,
'provider' => $this->integrationToken->provider,
'rotated' => array_key_exists('token', $updates),
]);
$this->dispatch(
'integration-token-updated',
uuid: $this->integrationToken->uuid,
name: $this->integrationToken->name,
capabilities: $this->integrationToken->capabilities,
);
$this->dispatch('success', 'Integration token updated successfully.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function delete(string $password = ''): void
{
$this->authorize('delete', $this->integrationToken);
if ($this->integrationToken->secretManagerLinks()->exists()) {
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
return;
}
if ($this->integrationToken->managedDnsRecords()->exists()) {
$this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
return;
}
$uuid = $this->integrationToken->uuid;
$name = $this->integrationToken->name;
$provider = $this->integrationToken->provider;
$this->integrationToken->delete();
auditLog('ui.integration_token.deleted', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $uuid,
'integration_token_name' => $name,
'provider' => $provider,
]);
$this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
$this->dispatch('close-modal');
$this->dispatch('success', 'Integration token deleted successfully.');
}
public function refreshZones(CloudflareDnsProvider $cloudflare): void
{
$this->authorize('update', $this->integrationToken);
try {
$cloudflare->syncZones($this->integrationToken);
$this->integrationToken->refresh();
$this->loadZones();
$this->dispatch('success', "Cloudflare zones refreshed. {$this->zoneCount} accessible zones found.");
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.integration-token-editor');
}
private function loadZones(): void
{
$this->zones = $this->integrationToken->dnsZones()
->select(['id', 'integration_token_id', 'name', 'account_name'])
->withCount('managedRecords')
->orderBy('name')
->get()
->map(fn ($zone) => [
'id' => $zone->id,
'name' => $zone->name,
'account_name' => $zone->account_name,
'managed_records_count' => $zone->managed_records_count,
])
->all();
$this->zoneCount = count($this->zones);
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class IntegrationTokenForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = false;
public string $provider = 'cloudflare';
public string $name = '';
public string $token = '';
public array $capabilities = ['dns'];
public array $metadata = [];
public bool $automaticDns = true;
public function mount(): void
{
$this->authorize('create', IntegrationToken::class);
}
public function updatedProvider(): void
{
if ($this->provider === 'cloudflare') {
$this->capabilities = ['dns'];
$this->metadata = [];
$this->automaticDns = true;
} else {
$this->capabilities = ['secrets'];
$this->metadata = $this->provider === 'infisical'
? ['base_url' => 'https://app.infisical.com']
: [];
}
}
protected function rules(): array
{
$allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets';
$rules = [
'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:'.$allowedCapability],
'automaticDns' => ['boolean'],
];
if ($this->provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
}
if ($this->provider === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
}
if ($this->provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
return $rules;
}
protected function messages(): array
{
return [
'capabilities.required' => 'Select at least one capability.',
'capabilities.min' => 'Select at least one capability.',
'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).',
];
}
public function addToken(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
{
$validated = $this->validate();
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) {
$metadata['automatic_dns'] = false;
}
try {
if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
$this->dispatch('error', $validator->errorMessage($validated['provider']));
return;
}
$integrationToken = DB::transaction(function () use ($validated, $metadata, $cloudflare): IntegrationToken {
$token = IntegrationToken::query()->create([
'provider' => $validated['provider'], 'name' => $validated['name'], 'token' => $validated['token'],
'capabilities' => $validated['capabilities'], 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id,
]);
if ($token->provider === 'cloudflare') {
$cloudflare->syncZones($token);
}
return $token;
});
auditLog('ui.integration_token.created', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $integrationToken->uuid,
'integration_token_name' => $integrationToken->name,
'provider' => $integrationToken->provider,
]);
$this->reset(['name', 'token']);
$this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class);
if ($this->modal_mode) {
$this->dispatch('close-modal');
}
$this->dispatch('success', 'Integration token added successfully.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.integration-token-form');
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Component;
class IntegrationTokens extends Component
{
use AuthorizesRequests;
public $tokens;
public function mount(): void
{
$this->authorize('viewAny', IntegrationToken::class);
$this->loadTokens();
}
#[On('integrationTokenAdded')]
public function loadTokens(): void
{
$this->tokens = IntegrationToken::ownedByCurrentTeam()->withCount('dnsZones')->latest()->get();
}
public function deleteToken(int $tokenId, string $password = ''): void
{
$token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
if ($token->secretManagerLinks()->exists()) {
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
return;
}
if ($token->managedDnsRecords()->exists()) {
$this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
return;
}
$tokenUuid = $token->uuid;
$tokenName = $token->name;
$provider = $token->provider;
$token->delete();
auditLog('ui.integration_token.deleted', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $tokenUuid,
'integration_token_name' => $tokenName,
'provider' => $provider,
]);
$this->loadTokens();
$this->dispatch('success', 'Integration token deleted successfully.');
}
public function render()
{
return view('livewire.security.integration-tokens');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Server\Analytics;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Server $server;
public function mount(string $server_uuid): void
{
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->authorize('view', $this->server);
}
public function render(): View
{
return view('livewire.server.analytics.show');
}
}
+33 -5
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Charts extends Component
@@ -23,15 +24,44 @@ class Charts extends Component
public bool $poll = true;
#[Validate(['required', 'integer', 'min:1'])]
public int|string $sentinelMetricsRefreshRateSeconds;
#[Validate(['required', 'integer', 'min:1'])]
public int|string $sentinelMetricsHistoryDays;
#[Validate(['required', 'integer', 'min:10'])]
public int|string $sentinelPushIntervalSeconds;
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
$this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
$this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function saveMetricsSettings(): void
{
try {
$this->authorize('update', $this->server);
$this->validate();
$this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
$this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays;
$this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds;
$this->server->settings->save();
$this->dispatch('success', 'Metrics settings updated. Restarting Sentinel.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function toggleMetrics(): void
{
try {
@@ -70,11 +100,9 @@ class Charts extends Component
try {
$cpuMetrics = $this->server->getCpuMetrics($this->interval);
$memoryMetrics = $this->server->getMemoryMetrics($this->interval);
$this->dispatch("refreshChartData-{$this->chartId}-cpu", [
'seriesData' => $cpuMetrics,
]);
$this->dispatch("refreshChartData-{$this->chartId}-memory", [
'seriesData' => $memoryMetrics,
$this->dispatch("refreshChartData-{$this->chartId}-metrics", [
'cpuSeries' => $cpuMetrics,
'memorySeries' => $memoryMetrics,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
+7
View File
@@ -134,6 +134,13 @@ class DockerCleanup extends Component
try {
$this->authorize('update', $this->server);
DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks);
auditLog('ui.server.docker_cleanup_started', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'delete_unused_volumes' => $this->deleteUnusedVolumes,
'delete_unused_networks' => $this->deleteUnusedNetworks,
]);
$this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.');
} catch (\Throwable $e) {
return handleError($e, $this);
+72
View File
@@ -177,6 +177,49 @@ class LogDrains extends Component
}
}
public function toggleLogDrain(string $type): void
{
$previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled;
$previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
$previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
try {
$this->authorize('update', $this->server);
$this->resetErrorBag();
$enabledProperty = $this->enabledProperty($type);
if ($this->{$enabledProperty}) {
$this->{$enabledProperty} = false;
} else {
$this->validateLogDrainSettings($type);
$this->isLogDrainNewRelicEnabled = $type === 'newrelic';
$this->isLogDrainAxiomEnabled = $type === 'axiom';
$this->isLogDrainCustomEnabled = $type === 'custom';
}
$this->syncData(true);
if ($this->server->isLogDrainEnabled()) {
StartLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service started.');
} else {
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (\Throwable $e) {
// Restore the previously persisted enabled flags so the UI/DB never
// claim a runtime state that the Start/StopLogDrain action failed to apply.
$this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled;
$this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
$this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
$this->server->settings->save();
$this->syncData();
handleError($e, $this);
}
}
public function submit()
{
try {
@@ -192,4 +235,33 @@ class LogDrains extends Component
{
return view('livewire.server.log-drains');
}
private function enabledProperty(string $type): string
{
return match ($type) {
'newrelic' => 'isLogDrainNewRelicEnabled',
'axiom' => 'isLogDrainAxiomEnabled',
'custom' => 'isLogDrainCustomEnabled',
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
private function validateLogDrainSettings(string $type): void
{
match ($type) {
'newrelic' => $this->validate([
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainNewRelicBaseUri' => ['required', 'url'],
]),
'axiom' => $this->validate([
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
]),
'custom' => $this->validate([
'logDrainCustomConfig' => ['required'],
'logDrainCustomConfigParser' => ['string', 'nullable'],
]),
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
}
+16
View File
@@ -101,6 +101,11 @@ class Navbar extends Component
// Always use background job for all servers
RestartProxyJob::dispatch($this->server);
auditLog('ui.proxy.restarted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
} catch (\Throwable $e) {
$this->restartInitiated = false;
@@ -125,6 +130,11 @@ class Navbar extends Component
try {
$this->authorize('manageProxy', $this->server);
$activity = StartProxy::run($this->server, force: true);
auditLog('ui.proxy.started', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -136,6 +146,12 @@ class Navbar extends Component
try {
$this->authorize('manageProxy', $this->server);
StopProxy::dispatch($this->server, $forceStop);
auditLog('ui.proxy.stopped', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'force' => $forceStop,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
-1
View File
@@ -56,7 +56,6 @@ class Proxy extends Component
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
$this->syncData(false);
$this->loadProxyConfiguration();
$this->clearAppliedTraefikBranchWarning();
}

Some files were not shown because too many files have changed in this diff Show More