diff --git a/.env.testing b/.env.testing index 1a73117986..d445b5afed 100644 --- a/.env.testing +++ b/.env.testing @@ -1,6 +1,7 @@ APP_ENV=testing APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_DEBUG=true +APP_MAINTENANCE_DRIVER=file DB_CONNECTION=testing diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index b256eb2255..f9e92e08f1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -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()) { diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index cd7e083286..cb1c517539 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -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); } diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php new file mode 100644 index 0000000000..4b59fe7911 --- /dev/null +++ b/app/Actions/Database/StartDatabaseImport.php @@ -0,0 +1,199 @@ +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.'); + } + } +} diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index ddd930f278..078d557f57 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -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()) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index cc017e3514..3b9cba28f4 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -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) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 2f030ae299..a05da25efd 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -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()) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 097e19f7b2..ff338aa99f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -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"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index d21ee02fb1..cff8d0b363 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -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()) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index f70e8f3cfd..f9dd7a3c4f 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -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()) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 8d65453f70..41ece532b1 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -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) { diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 44602f0619..c69863c572 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -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); } diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index f90e007089..5cf5658f8f 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -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, + ]; + } } diff --git a/app/Actions/Server/ConfigureTrafficAnalytics.php b/app/Actions/Server/ConfigureTrafficAnalytics.php new file mode 100644 index 0000000000..9121443c62 --- /dev/null +++ b/app/Actions/Server/ConfigureTrafficAnalytics.php @@ -0,0 +1,33 @@ +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); + } + } +} diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2e08ec6ad9..552445d728 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -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'; diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 84be7f2068..57fd4f1d7c 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -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', + ]; + } } diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 3a37a7328b..edcd4a1edd 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -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', diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index ab0ca94943..2b06e06011 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -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', diff --git a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php new file mode 100644 index 0000000000..e4a2ba0dfe --- /dev/null +++ b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php @@ -0,0 +1,5 @@ + $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, + ); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryDocument.php b/app/Auth/Oidc/OidcDiscoveryDocument.php new file mode 100644 index 0000000000..d17061c51d --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryDocument.php @@ -0,0 +1,61 @@ + $supportedScopes + * @param array $supportedClaims + * @param array $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 $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 + */ + private static function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_map('strval', $value)); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php new file mode 100644 index 0000000000..0847afc9a7 --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -0,0 +1,97 @@ +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 + */ + 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; + } + } +} diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php new file mode 100644 index 0000000000..a8563611dd --- /dev/null +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -0,0 +1,199 @@ + $jwks + * @return array + */ + 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 $jwks + * @return array + */ + 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 $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 $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 $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 $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 $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.'); + } + } +} diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php new file mode 100644 index 0000000000..645130e019 --- /dev/null +++ b/app/Auth/Oidc/OidcUser.php @@ -0,0 +1,32 @@ + + */ + public array $idTokenClaims = []; + + /** + * @param array $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; + } +} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php new file mode 100644 index 0000000000..383b0cc910 --- /dev/null +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -0,0 +1,299 @@ + + */ + 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 + */ + 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 $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 + */ + 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 + */ + 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 $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}"; + } +} diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 347ea94193..65f686ba61 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -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(); diff --git a/app/Data/Traffic/TrafficBreakdownData.php b/app/Data/Traffic/TrafficBreakdownData.php new file mode 100644 index 0000000000..a98e5323a4 --- /dev/null +++ b/app/Data/Traffic/TrafficBreakdownData.php @@ -0,0 +1,23 @@ +teamId}")]; + } +} diff --git a/app/Exceptions/DnsRecordConflictException.php b/app/Exceptions/DnsRecordConflictException.php new file mode 100644 index 0000000000..00f5c73b72 --- /dev/null +++ b/app/Exceptions/DnsRecordConflictException.php @@ -0,0 +1,16 @@ +/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'); diff --git a/app/Http/Controllers/Api/ApplicationSecretManagerController.php b/app/Http/Controllers/Api/ApplicationSecretManagerController.php new file mode 100644 index 0000000000..c8c311766d --- /dev/null +++ b/app/Http/Controllers/Api/ApplicationSecretManagerController.php @@ -0,0 +1,123 @@ + []]], + 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, + ]); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 67fd515bcd..4583600995 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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, diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php new file mode 100644 index 0000000000..da452bb303 --- /dev/null +++ b/app/Http/Controllers/Api/AuditEventsController.php @@ -0,0 +1,80 @@ +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)); + } +} diff --git a/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php new file mode 100644 index 0000000000..a2ec2b93be --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php @@ -0,0 +1,125 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 881aa5a897..6d9fa1cec7 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -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); diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php new file mode 100644 index 0000000000..13a225a107 --- /dev/null +++ b/app/Http/Controllers/Api/IntegrationTokensController.php @@ -0,0 +1,108 @@ + []]], + 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); + } +} diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 33d21ba5d0..43ce742168 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -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, diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index eb137c5349..16eff1ba18 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -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.']); } diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index fb40745c9e..77bc16c1c2 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -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); diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 480ff4e557..81e68ceb31 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -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([ diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 109f8915a1..a21850c9bf 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -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; + } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index aca4293919..b1cb8d853d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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, ]; diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 6d42398c21..a980f36221 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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|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 + */ + 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, ]); } diff --git a/app/Jobs/ConfigureDnsRecordJob.php b/app/Jobs/ConfigureDnsRecordJob.php new file mode 100644 index 0000000000..4e0ea64718 --- /dev/null +++ b/app/Jobs/ConfigureDnsRecordJob.php @@ -0,0 +1,87 @@ +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); + } +} diff --git a/app/Jobs/DatabaseStartJob.php b/app/Jobs/DatabaseStartJob.php new file mode 100644 index 0000000000..e21ee38c61 --- /dev/null +++ b/app/Jobs/DatabaseStartJob.php @@ -0,0 +1,88 @@ +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)); + } + } +} diff --git a/app/Listeners/CleanupDatabaseImport.php b/app/Listeners/CleanupDatabaseImport.php new file mode 100644 index 0000000000..a3a36b8342 --- /dev/null +++ b/app/Listeners/CleanupDatabaseImport.php @@ -0,0 +1,67 @@ + */ + 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 $data + * @return list + */ + 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(), + ]); + } +} diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php new file mode 100644 index 0000000000..abf9d88cfd --- /dev/null +++ b/app/Livewire/Analytics.php @@ -0,0 +1,603 @@ + uuid => name, for the server filter */ + public array $serverOptions = []; + + /** @var array 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 + */ + 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> */ + public array $topApps = []; + + /** @var array> */ + public array $topHosts = []; + + /** @var array> */ + public array $topPaths = []; + + /** @var array>> */ + 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 + */ + public array $series = []; + + public bool $hasSeries = false; + + /** + * Servers that could run traffic analytics but have it off β€” drives the nudge banner. + * + * @var array + */ + 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 */ + 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 + */ + 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 + */ + 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'); + } +} diff --git a/app/Livewire/Concerns/BuildsTrafficChartPayload.php b/app/Livewire/Concerns/BuildsTrafficChartPayload.php new file mode 100644 index 0000000000..66af46efa5 --- /dev/null +++ b/app/Livewire/Concerns/BuildsTrafficChartPayload.php @@ -0,0 +1,125 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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, series: array} + */ + 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)), + ]; + } +} diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php new file mode 100644 index 0000000000..692445c3c0 --- /dev/null +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -0,0 +1,261 @@ +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 $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 $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; +} diff --git a/app/Livewire/Dashboard/TrafficAnalytics.php b/app/Livewire/Dashboard/TrafficAnalytics.php new file mode 100644 index 0000000000..532460d454 --- /dev/null +++ b/app/Livewire/Dashboard/TrafficAnalytics.php @@ -0,0 +1,180 @@ + + */ + 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'); + } +} diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 8ea4bbc958..cb31e6c111 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -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 { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 5bd55137b5..ea626ed57a 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -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 { diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index 7caacdb916..cae1c3d689 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -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 { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index fe84710bdf..644252c1a3 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -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 { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 51239b8621..f999294477 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -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); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index a3480ada69..fb537fc7d9 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -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 { diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 04f58dadd2..69f27b0e55 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -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'); diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index a9e1c0be28..a57d529bcb 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -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 { diff --git a/app/Livewire/Project/Application/Analytics.php b/app/Livewire/Project/Application/Analytics.php new file mode 100644 index 0000000000..0c5e30b737 --- /dev/null +++ b/app/Livewire/Project/Application/Analytics.php @@ -0,0 +1,243 @@ +>> */ + 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 + */ + public array $series = []; + + public bool $hasSeries = false; + + /** @var array */ + 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 + */ + 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'); + } +} diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index b60f543ba5..3abc2da73c 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -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); diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index ae37e19cdb..929c02e93e 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -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 { diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 6c75cd7a61..830a4eace8 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -156,6 +156,11 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping application.
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); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index acc7dc8608..79a393c192 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -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); diff --git a/app/Livewire/Project/Application/TrafficOverview.php b/app/Livewire/Project/Application/TrafficOverview.php new file mode 100644 index 0000000000..223c6047ae --- /dev/null +++ b/app/Livewire/Project/Application/TrafficOverview.php @@ -0,0 +1,73 @@ +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' +
+ HTML; + } + + public function render() + { + return view('livewire.project.application.traffic-overview'); + } +} diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index fff2b7fbf5..ad032779b3 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -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) { diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 4a709d2b96..d938ebde8d 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -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', [ diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index 8c83e33556..39a1960119 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -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); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index f2f8fa387d..993200b578 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -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, + ]); + } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 2d58746554..e5a359b30d 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -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 2>/dev/null || cat ) | 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 << "\$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); } } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 56c9f651f6..79c89322a7 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -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 { diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 0e7fed960f..d692a71546 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -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', [ diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index adb19a3135..10079276f2 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -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(); diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 94fb4b4eb3..9262b9847e 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -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); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 1dcb7c7810..15b4410a5f 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -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; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index d42184f650..e47d3818fe 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -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() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index da55dee197..c2f0059399 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -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'); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index dd00be25cc..61b4b2d2ed 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -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, [ diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 30d1024621..c121f1b93b 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -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); diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php new file mode 100644 index 0000000000..c0641b56c5 --- /dev/null +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -0,0 +1,290 @@ + 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 $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, + ]); + } +} diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index fcd7752a0c..3dadfb46f4 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -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. */ diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php deleted file mode 100644 index c70ebc57fd..0000000000 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ /dev/null @@ -1,201 +0,0 @@ - '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; - } -} diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a8e2d72df1..86023628e2 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -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()); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 5a978ac84f..a1cc4db19f 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -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); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php new file mode 100644 index 0000000000..d10ebb1c14 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -0,0 +1,213 @@ + */ + 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); + } +} diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php new file mode 100644 index 0000000000..482e17bc37 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -0,0 +1,139 @@ +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'); + } +} diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php new file mode 100644 index 0000000000..34b2b38a07 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokens.php @@ -0,0 +1,63 @@ +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'); + } +} diff --git a/app/Livewire/Server/Analytics/Show.php b/app/Livewire/Server/Analytics/Show.php new file mode 100644 index 0000000000..abcf904e0c --- /dev/null +++ b/app/Livewire/Server/Analytics/Show.php @@ -0,0 +1,26 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('view', $this->server); + } + + public function render(): View + { + return view('livewire.server.analytics.show'); + } +} diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 1cda771a7c..567034c801 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -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); diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 40dd92d87e..d0a8d8ca9d 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -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); diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 5ce657f001..9319c856c0 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -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.'), + }; + } } diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index d9f70ea253..242b0971ec 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -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); } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 296fd4da5d..0454d97049 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -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(); } diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index f07799fbe5..b6444e573d 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -20,15 +20,6 @@ class Sentinel extends Component public ?string $sentinelUpdatedAt = null; - #[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; - #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; @@ -56,18 +47,12 @@ class Sentinel extends Component $this->validate(); $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; - $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->sentinel_custom_url = $this->sentinelCustomUrl; $this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled; $this->server->settings->save(); } else { $this->isMetricsEnabled = $this->server->settings->is_metrics_enabled; $this->sentinelToken = $this->server->settings->sentinel_token; - $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; $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php new file mode 100644 index 0000000000..c6d96df488 --- /dev/null +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -0,0 +1,111 @@ +authorize('update', $this->server); + $this->syncData(); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->validate(); + $this->server->settings->traffic_topn = $this->trafficTopn; + $this->server->settings->traffic_sample_threshold = $this->trafficSampleThreshold; + $this->server->settings->traffic_retention_1h_days = $this->trafficRetention1hDays; + $this->server->settings->traffic_retention_1d_days = $this->trafficRetention1dDays; + $this->server->settings->is_geoip_enabled = $this->isGeoipEnabled; + $this->server->settings->geoip_refresh_days = $this->geoipRefreshDays; + $this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey; + $this->server->settings->save(); + + return; + } + + $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); + $this->trafficTopn = $this->server->settings->traffic_topn; + $this->trafficSampleThreshold = $this->server->settings->traffic_sample_threshold; + $this->trafficRetention1hDays = $this->server->settings->traffic_retention_1h_days; + $this->trafficRetention1dDays = $this->server->settings->traffic_retention_1d_days; + $this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled; + $this->geoipRefreshDays = $this->server->settings->geoip_refresh_days; + $this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key; + } + + public function toggleTrafficAnalytics(): void + { + try { + $this->authorize('update', $this->server); + if ($this->server->isSwarm() || $this->server->isBuildServer()) { + $this->dispatch('error', 'Traffic analytics is not supported on Swarm/Build servers.'); + + return; + } + + $enable = ! $this->server->isTrafficAnalyticsEnabled(); + ConfigureTrafficAnalytics::run($this->server, $enable); + $this->server->refresh(); + $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); + $this->dispatch('trafficAnalyticsStateChanged')->to(Analytics::class); + $this->dispatch('success', $enable + ? 'Traffic analytics enabled. Restarting proxy and Sentinel.' + : 'Traffic analytics disabled. Restarting proxy and Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function saveTrafficAnalyticsSettings(): void + { + try { + $this->authorize('update', $this->server); + $this->syncData(true); + $this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.server.traffic-analytics-settings'); + } +} diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php index db8999c268..9fe37c10ca 100644 --- a/app/Livewire/Server/TransferImport.php +++ b/app/Livewire/Server/TransferImport.php @@ -123,6 +123,15 @@ class TransferImport extends Component $this->lastWarnings = array_values((array) data_get($result, 'warnings', [])); $this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid'); + if (! $dryRun) { + auditLog('ui.server.imported', [ + 'team_id' => $teamId, + 'server_uuid' => $this->importedServerUuid, + 'claimed' => (bool) data_get($result, 'claimed'), + 'adopt_mode' => $this->adoptMode, + ]); + } + if ($dryRun) { $this->dispatch('success', 'Dry run completed β€” nothing was written.'); } elseif (data_get($result, 'claimed')) { diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 45aff3f3c9..4bb89c9f08 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -19,6 +19,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_registration_enabled; + #[Validate('boolean')] + public bool $disable_registration_when_oauth_enabled; + #[Validate('boolean')] public bool $do_not_track; @@ -61,6 +64,7 @@ class Advanced extends Component { return [ 'is_registration_enabled' => 'boolean', + 'disable_registration_when_oauth_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], @@ -87,6 +91,7 @@ class Advanced extends Component $this->allowed_ips = $this->settings->allowed_ips; $this->do_not_track = $this->settings->do_not_track; $this->is_registration_enabled = $this->settings->is_registration_enabled; + $this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled; $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; @@ -203,6 +208,7 @@ class Advanced extends Component try { $this->authorize('update', $this->settings); $this->settings->is_registration_enabled = $this->is_registration_enabled; + $this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled; $this->settings->do_not_track = $this->do_not_track; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; $this->settings->custom_dns_servers = $this->custom_dns_servers; diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 4b5857db50..975ce9a241 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -160,30 +160,59 @@ class SettingsEmail extends Component $this->instantSave('Resend'); } + public function toggleSmtp() + { + try { + $this->resetErrorBag(); + + if ($this->smtpEnabled) { + $this->smtpEnabled = false; + $this->syncData(true); + $this->dispatch('success', 'SMTP settings updated.'); + } else { + $this->validateSmtpSettings(); + $this->smtpEnabled = true; + $this->resendEnabled = false; + $this->submitSmtp(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + + public function toggleResend() + { + try { + $this->resetErrorBag(); + + if ($this->resendEnabled) { + $this->resendEnabled = false; + $this->syncData(true); + $this->dispatch('success', 'Resend settings updated.'); + } else { + $this->validateResendSettings(); + $this->resendEnabled = true; + $this->smtpEnabled = false; + $this->submitResend(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + public function submitSmtp() { try { $this->authorize('update', $this->settings); - $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; + } $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; @@ -210,17 +239,11 @@ class SettingsEmail extends Component { try { $this->authorize('update', $this->settings); - $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; + } $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -237,6 +260,45 @@ class SettingsEmail 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 { diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 4082718191..3b24d0cd2e 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -2,53 +2,89 @@ namespace App\Livewire; +use App\Models\InstanceSettings; use App\Models\OauthSetting; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\RedirectResponse; +use Illuminate\Validation\ValidationException; use Livewire\Component; class SettingsOauth extends Component { use AuthorizesRequests; + public InstanceSettings $settings; + public $oauth_settings_map; - protected function rules() + public ?string $selectedProvider = null; + + public bool $disable_registration_when_oauth_enabled = false; + + protected function rules(): array { - return OauthSetting::all()->reduce(function ($carry, $setting) { - $carry["oauth_settings_map.$setting->provider.enabled"] = 'required'; - $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable'; + return $this->validationRules(); + } + + private function validationRules(?string $provider = null): array + { + $rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) { + if ($provider !== null && $setting->provider !== $provider) { + return $carry; + } + + $carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean'; + $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https'; + $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https'; + $carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255'; + $carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000'; + $carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600'; return $carry; }, []); + + if ($provider === null) { + $rules['disable_registration_when_oauth_enabled'] = 'boolean'; + } + + return $rules; } - public function mount() + public function mount(?string $provider = null): ?RedirectResponse { if (! isInstanceAdmin()) { return redirect()->route('home'); } - $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) { - $carry[$setting->provider] = [ - 'id' => $setting->id, - 'provider' => $setting->provider, - 'enabled' => $setting->enabled, - 'client_id' => $setting->client_id, - 'client_secret' => $setting->client_secret, - 'redirect_uri' => $setting->redirect_uri, - 'tenant' => $setting->tenant, - 'base_url' => $setting->base_url, - ]; - return $carry; - }, []); + $this->settings = instanceSettings(); + $this->selectedProvider = $provider; + $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled; + $this->oauth_settings_map = OauthSetting::all() + ->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider) + ->reduce(function ($carry, $setting) { + $carry[$setting->provider] = $this->oauthSettingToArray($setting); + + return $carry; + }, []); + + if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { + abort(404); + } + + return null; } - private function updateOauthSettings(?string $provider = null) + private function updateOauthSettings(?string $provider = null): void { + $this->validate($this->validationRules($provider)); + if ($provider) { $oauthData = $this->oauth_settings_map[$provider]; $oauth = OauthSetting::find($oauthData['id']); @@ -57,78 +93,128 @@ class SettingsOauth extends Component throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - $oauth->fill([ - 'enabled' => $oauthData['enabled'], - 'client_id' => $oauthData['client_id'], - 'client_secret' => $oauthData['client_secret'], - 'redirect_uri' => $oauthData['redirect_uri'], - 'tenant' => $oauthData['tenant'], - 'base_url' => $oauthData['base_url'], - ]); - - if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) { - $oauth->update(['enabled' => false]); - throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); - } + $this->fillOauthSetting($oauth, $oauthData); + $this->ensureProviderCanBeEnabled($oauth); $oauth->save(); - // Update the array with fresh data - $this->oauth_settings_map[$provider] = [ - 'id' => $oauth->id, - 'provider' => $oauth->provider, - 'enabled' => $oauth->enabled, - 'client_id' => $oauth->client_id, - 'client_secret' => $oauth->client_secret, - 'redirect_uri' => $oauth->redirect_uri, - 'tenant' => $oauth->tenant, - 'base_url' => $oauth->base_url, - ]; + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); $this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!'); - } else { - $errors = []; - foreach (array_values($this->oauth_settings_map) as $settingData) { - $oauth = OauthSetting::find($settingData['id']); - if (! $oauth) { - $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; - - continue; - } - - $oauth->fill([ - 'enabled' => $settingData['enabled'], - 'client_id' => $settingData['client_id'], - 'client_secret' => $settingData['client_secret'], - 'redirect_uri' => $settingData['redirect_uri'], - 'tenant' => $settingData['tenant'], - 'base_url' => $settingData['base_url'], - ]); - - if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) { - $oauth->enabled = false; - $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; - } - - $oauth->save(); - - // Update the array with fresh data - $this->oauth_settings_map[$oauth->provider] = [ - 'id' => $oauth->id, - 'provider' => $oauth->provider, - 'enabled' => $oauth->enabled, - 'client_id' => $oauth->client_id, - 'client_secret' => $oauth->client_secret, - 'redirect_uri' => $oauth->redirect_uri, - 'tenant' => $oauth->tenant, - 'base_url' => $oauth->base_url, - ]; - } - - if (! empty($errors)) { - $this->dispatch('error', implode('
', $errors)); - } + return; } + + $errors = []; + foreach (array_values($this->oauth_settings_map) as $settingData) { + $oauth = OauthSetting::find($settingData['id']); + + if (! $oauth) { + $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; + + continue; + } + + $this->fillOauthSetting($oauth, $settingData); + + if ($oauth->enabled && ! $oauth->couldBeEnabled()) { + $oauth->enabled = false; + $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; + } + + if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + $oauth->enabled = false; + $errors[] = "OIDC scopes must include 'openid'. The provider has been disabled."; + } + + $oauth->save(); + $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth); + } + + instanceSettings()->update([ + 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, + ]); + + if (! empty($errors)) { + $this->dispatch('error', implode('
', $errors)); + } + } + + private function fillOauthSetting(OauthSetting $oauth, array $data): void + { + $oauth->fill([ + 'enabled' => (bool) ($data['enabled'] ?? false), + 'client_id' => $data['client_id'] ?? null, + 'client_secret' => $data['client_secret'] ?? null, + 'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null), + 'tenant' => $data['tenant'] ?? null, + 'base_url' => $this->nullableString($data['base_url'] ?? null), + 'custom_label' => $data['custom_label'] ?? null, + 'scopes' => $data['scopes'] ?? null, + 'allow_registration' => (bool) ($data['allow_registration'] ?? false), + 'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false), + 'require_email_verified' => (bool) ($data['require_email_verified'] ?? true), + 'use_pkce' => (bool) ($data['use_pkce'] ?? true), + 'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60), + ]); + } + + private function nullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function ensureProviderCanBeEnabled(OauthSetting $oauth): void + { + if (! $oauth->enabled) { + return; + } + + if (! $oauth->couldBeEnabled()) { + $oauth->update(['enabled' => false]); + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); + } + + if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + $oauth->update(['enabled' => false]); + throw new \Exception("OIDC scopes must include 'openid'."); + } + } + + private function oauthSettingToArray(OauthSetting $setting): array + { + return [ + 'id' => $setting->id, + 'provider' => $setting->provider, + 'enabled' => $setting->enabled, + 'client_id' => $setting->client_id, + 'client_secret' => $setting->client_secret, + 'redirect_uri' => $setting->redirect_uri, + 'tenant' => $setting->tenant, + 'base_url' => $setting->base_url, + 'custom_label' => $setting->custom_label, + 'scopes' => $setting->scopes ?: 'openid email profile', + 'allow_registration' => $setting->allow_registration, + 'auto_join_root_team' => $setting->auto_join_root_team, + 'require_email_verified' => $setting->require_email_verified ?? true, + 'use_pkce' => $setting->use_pkce ?? true, + 'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60, + 'label' => $this->providerLabel($setting->provider), + ]; + } + + public function providerLabel(string $provider): string + { + return match ($provider) { + 'oidc' => 'OpenID Connect', + 'gitlab' => 'GitLab', + default => str($provider)->headline()->toString(), + }; } public function instantSave(string $provider) @@ -141,56 +227,88 @@ class SettingsOauth extends Component } } - public function toggleProvider(string $provider): mixed + public function toggleProvider(string $provider) { try { $this->authorize('update', instanceSettings()); if (! array_key_exists($provider, $this->oauth_settings_map)) { - throw new \Exception('OAuth provider not found.'); + abort(404); } - $enabling = ! $this->oauth_settings_map[$provider]['enabled']; - if ($enabling) { - $this->validate($this->providerRules($provider)); + if (! (bool) $this->oauth_settings_map[$provider]['enabled']) { + $this->validateProviderCanBeEnabled($provider); } - $this->oauth_settings_map[$provider]['enabled'] = $enabling; + $this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled']; $this->updateOauthSettings($provider); - } catch (\Throwable $e) { + } catch (\Exception $e) { + $oauth = OauthSetting::where('provider', $provider)->first(); + if ($oauth) { + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); + } + return handleError($e, $this); } - - return null; } - private function providerRules(string $provider): array + private function validateProviderCanBeEnabled(string $provider): void { - $prefix = "oauth_settings_map.$provider"; - $rules = [ - "$prefix.client_id" => 'required', - "$prefix.client_secret" => 'required', - ]; + $this->validate($this->validationRules($provider)); - if ($provider === 'azure') { - $rules["$prefix.tenant"] = 'required'; + $oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']); + if (! $oauth) { + throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - if (in_array($provider, ['authentik', 'clerk'], true)) { - $rules["$prefix.base_url"] = 'required'; + $this->fillOauthSetting($oauth, [ + ...$this->oauth_settings_map[$provider], + 'enabled' => true, + ]); + + if (! $oauth->couldBeEnabled()) { + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); } - return $rules; + if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + throw new \Exception("OIDC scopes must include 'openid'."); + } } - public function submit() + public function saveRegistrationPolicy(): void + { + $this->authorize('update', instanceSettings()); + $this->validate([ + 'disable_registration_when_oauth_enabled' => 'boolean', + ]); + + instanceSettings()->update([ + 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, + ]); + + $this->dispatch('success', 'Authentication settings updated successfully!'); + } + + public function submit(): void { try { $this->authorize('update', instanceSettings()); - $this->updateOauthSettings(); - $this->dispatch('success', 'Instance settings updated successfully!'); - } catch (\Throwable $e) { - return handleError($e, $this); + $this->updateOauthSettings($this->selectedProvider); + + if ($this->selectedProvider === null) { + $this->dispatch('success', 'Instance settings updated successfully!'); + } + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + if ($this->selectedProvider !== null) { + $oauth = OauthSetting::where('provider', $this->selectedProvider)->first(); + if ($oauth) { + $this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth); + } + } + + handleError($e, $this); } } } diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php new file mode 100644 index 0000000000..53cb203eff --- /dev/null +++ b/app/Livewire/Team/AuditLog.php @@ -0,0 +1,73 @@ +user()->isAdminOfTeam(currentTeam()->id), 403); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedAction(): void + { + $this->resetPage(); + } + + public function updatedSource(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(10, min(100, $this->perPage)); + $this->resetPage(); + } + + public function render(): View + { + $search = trim($this->search); + $teamId = currentTeam()->id; + $canViewInstanceEvents = $teamId === 0 && isInstanceAdmin(); + $visibleEvents = AuditEvent::query()->visibleToTeam($teamId, $canViewInstanceEvents); + $actionOptions = [ + ['value' => 'all', 'label' => 'All actions'], + ...$visibleEvents->clone() + ->select('action') + ->distinct() + ->orderBy('action') + ->pluck('action') + ->map(fn (string $action): array => ['value' => $action, 'label' => Str::headline($action)]) + ->all(), + ]; + $events = AuditEvent::query() + ->visibleToTeam($teamId, $canViewInstanceEvents) + ->filtered($search, $this->action, $this->source) + ->latestFirst() + ->paginate($this->perPage); + + return view('livewire.team.audit-log', ['actionOptions' => $actionOptions, 'events' => $events]); + } +} diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 8ecafc417c..b66c49ac9e 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -22,6 +22,8 @@ class Invitations extends Component $this->authorize('manageInvitations', currentTeam()); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); + $invitationEmail = $invitation->email; + $invitationUuid = $invitation->uuid; DB::transaction(function () use ($invitation): void { $user = User::whereEmail($invitation->email)->first(); if (filled($user)) { @@ -30,6 +32,11 @@ class Invitations extends Component $invitation->delete(); }); + auditLog('ui.team_invitation.revoked', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitationUuid, + 'invitation_email' => $invitationEmail, + ]); $this->refreshInvitations(); $this->dispatch('success', 'Invitation revoked.'); } catch (\Exception) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index a93bf8dd92..d6ea836075 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -103,6 +103,13 @@ class InviteLink extends Component 'link' => $link, 'via' => $sendEmail ? 'email' : 'link', ]); + auditLog('ui.team_invitation.created', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitation->uuid, + 'invitation_email' => $invitation->email, + 'role' => $invitation->role, + 'via' => $invitation->via, + ]); if ($sendEmail) { $mail = new MailMessage; $mail->view('emails.invitation-link', [ diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index ab3f7938a1..f28087056f 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -30,6 +30,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::ADMIN); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -50,6 +51,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::OWNER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -70,6 +72,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::MEMBER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -91,6 +94,12 @@ class Member extends Component RevokeUserTeamTokens::forUserTeam($this->member, $teamId); $this->member->clearStoredTeamIfMatches($teamId); }); + auditLog('ui.team_member.removed', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + ]); // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); Cache::forget("user:{$this->member->id}:team:{$teamId}"); @@ -104,4 +113,15 @@ class Member extends Component { return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role; } + + private function auditRoleUpdate(int $teamId, Role $role): void + { + auditLog('ui.team_member.role_updated', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + 'role' => $role->value, + ]); + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 38b8c5b0e2..2e559b7b78 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -10,11 +10,14 @@ use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiffer; use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; +use App\Traits\Auditable; + use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -124,10 +127,8 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; - /** @use HasFactory */ - use HasFactory; + use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -399,6 +400,7 @@ class Application extends BaseModel $application->persistentStorages()->delete(); $application->environment_variables()->delete(); $application->environment_variables_preview()->delete(); + $application->secretManagerLink()->delete(); foreach ($application->scheduled_tasks as $task) { $task->delete(); } diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index ee190532c4..f16f7f8f96 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; +use App\Enums\ApplicationDeploymentStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -44,6 +45,44 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + protected static function booted(): void + { + static::created(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() || ! $deployment->rollback) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.application.rollback", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + 'commit' => $deployment->commit, + ]); + }); + + static::updated(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() + || ! $deployment->wasChanged('status') + || $deployment->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.deployment.cancelled", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + ]); + }); + } + protected $fillable = [ 'application_id', 'deployment_uuid', diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 91c38b8790..18b26f454f 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -32,6 +32,7 @@ use OpenApi\Attributes as OA; 'is_stripprefix_enabled' => ['type' => 'boolean'], 'connect_to_docker_network' => ['type' => 'boolean'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true], 'is_container_label_escape_enabled' => ['type' => 'boolean'], 'is_env_sorting_enabled' => ['type' => 'boolean'], 'is_container_label_readonly_enabled' => ['type' => 'boolean'], @@ -49,6 +50,12 @@ use OpenApi\Attributes as OA; )] class ApplicationSetting extends Model { + /** + * Keeps generated names (prefix, timestamp and for compose apps the service name) well below the + * 63 character DNS label limit, with room for a longer suffix in the future. + */ + public const MAX_CONTAINER_NAME_PREFIX_LENGTH = 30; + protected $casts = [ 'is_static' => 'boolean', 'is_spa' => 'boolean', @@ -106,6 +113,7 @@ class ApplicationSetting extends Model 'is_stripprefix_enabled', 'connect_to_docker_network', 'custom_internal_name', + 'custom_container_name_prefix', 'is_container_label_escape_enabled', 'is_env_sorting_enabled', 'is_container_label_readonly_enabled', @@ -121,6 +129,18 @@ class ApplicationSetting extends Model 'stop_grace_period', ]; + /** + * Like custom container names, a prefix must be unique per server so that uuid, custom container + * name and prefix each identify one container when resolving connections. + */ + public static function isContainerNamePrefixInUse(string $prefix, Server $server, ?int $ignoreApplicationId = null): bool + { + return $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) { + return $application->id !== $ignoreApplicationId + && in_array($prefix, [$application->uuid, $application->settings->custom_container_name_prefix, $application->settings->custom_internal_name], true); + }); + } + public function stopGracePeriodSeconds(): int { if ( diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php new file mode 100644 index 0000000000..2383dee267 --- /dev/null +++ b/app/Models/AuditEvent.php @@ -0,0 +1,211 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + public function scopeVisibleToTeam(Builder $query, int $teamId, bool $includeInstanceEvents = false): Builder + { + return $query->where(function (Builder $query) use ($includeInstanceEvents, $teamId): void { + $query->where('team_id', $teamId) + ->when($includeInstanceEvents, fn (Builder $query) => $query->orWhereNull('team_id')); + }); + } + + public function scopeFiltered( + Builder $query, + string $search = '', + string $action = 'all', + string $source = 'all', + bool $searchSensitiveFields = true, + ): Builder { + return $query + ->when($action !== 'all', fn (Builder $query) => $query->where('action', $action)) + ->when($source !== 'all', fn (Builder $query) => $query->where('source', $source)) + ->when($search !== '', function (Builder $query) use ($search, $searchSensitiveFields): void { + $query->where(function (Builder $query) use ($search, $searchSensitiveFields): void { + $query->where('event', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhere('resource_name', 'like', "%{$search}%") + ->orWhere('actor_name', 'like', "%{$search}%") + ->when($searchSensitiveFields, fn (Builder $query) => $query->orWhere('actor_email', 'like', "%{$search}%")); + }); + }); + } + + public function scopeLatestFirst(Builder $query): Builder + { + return $query->latest('created_at')->latest('id'); + } + + /** + * @param array $context + */ + public static function record(string $event, array $context = []): void + { + try { + $attributes = self::attributesFor($event, $context); + + DB::afterCommit(function () use ($attributes): void { + defer(function () use ($attributes): void { + try { + self::query()->create($attributes); + } catch (Throwable $exception) { + Log::warning('Audit event persistence failed', [ + 'event' => $attributes['event'], + 'exception' => $exception::class, + ]); + } + })->always(); + }); + } catch (Throwable $exception) { + Log::warning('Audit event preparation failed', [ + 'event' => $event, + 'exception' => $exception::class, + ]); + } + } + + /** + * @param array $context + * @return array + */ + private static function attributesFor(string $event, array $context): array + { + $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') + ?? data_get($context, 'team_id') + ?? currentTeam()?->id + ?? self::teamIdFromContext($context); + + $parts = explode('.', $event); + $source = $parts[0] ?? 'system'; + $resourceType = data_get($context, 'resource') ?? ($parts[1] ?? null); + $action = data_get($context, 'action') ?? (end($parts) ?: 'event'); + $resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid'); + $resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name'); + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $actorType = match (true) { + in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source, + $token !== null => 'api_token', + $user !== null => 'user', + default => 'system', + }; + + return [ + 'team_id' => $teamId, + 'event' => $event, + 'source' => $source, + 'action' => $action, + 'actor_type' => $actorType, + 'actor_id' => $user?->id, + 'actor_name' => $user?->name, + 'actor_email' => $user?->email, + 'actor_token_id' => $token?->id, + 'actor_token_name' => $token?->name, + 'resource_type' => $resourceType, + 'resource_uuid' => $resourceUuid, + 'resource_name' => $resourceName, + 'description' => data_get($context, 'audit_description') + ?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)), + 'metadata' => self::redact($context), + 'ip_address' => app()->bound('request') ? request()->ip() : null, + 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null, + ]; + } + + /** + * @param array $context + */ + private static function teamIdFromContext(array $context): ?int + { + $applicationUuid = data_get($context, 'application_uuid'); + if (! is_string($applicationUuid) || $applicationUuid === '') { + return null; + } + + return Application::query() + ->where('uuid', $applicationUuid) + ->first()?->team()?->id; + } + + public static function pruneExpired(): int + { + return self::query() + ->where('created_at', '<', now()->subDays(90)) + ->delete(); + } + + /** + * @param array $context + */ + private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed + { + if ($preferredKey !== null && filled(data_get($context, $preferredKey))) { + return data_get($context, $preferredKey); + } + + $key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix)); + + return $key ? data_get($context, $key) : null; + } + + private static function redact(mixed $value, ?string $key = null): mixed + { + if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $key)) { + return '[REDACTED]'; + } + + if (! is_array($value)) { + return $value; + } + + return collect($value) + ->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [ + $itemKey => self::redact($item, (string) $itemKey), + ]) + ->all(); + } +} diff --git a/app/Models/DnsProviderZone.php b/app/Models/DnsProviderZone.php new file mode 100644 index 0000000000..0e099ee0c4 --- /dev/null +++ b/app/Models/DnsProviderZone.php @@ -0,0 +1,24 @@ +belongsTo(IntegrationToken::class); + } + + public function managedRecords(): HasMany + { + return $this->hasMany(ManagedDnsRecord::class); + } +} diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 1364d874a1..e98f13d21f 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -21,8 +22,8 @@ use OpenApi\Attributes as OA; )] class Environment extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 89188b31b1..e7dd8564bc 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -34,6 +35,8 @@ use OpenApi\Attributes as OA; )] class EnvironmentVariable extends BaseModel { + use Auditable; + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; protected $attributes = [ @@ -249,17 +252,21 @@ class EnvironmentVariable extends BaseModel protected function isShared(): Attribute { return Attribute::make( - get: function () { - $type = str($this->value)->after('{{')->before('.')->value; - if (str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}')) { - return true; - } - - return false; - } + get: fn () => $this->isSharedReference(), ); } + private function isSharedReference(): bool + { + if (blank($this->value)) { + return false; + } + + $types = implode('|', SHARED_VARIABLE_TYPES); + + return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1; + } + public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource, $server); @@ -302,6 +309,23 @@ class EnvironmentVariable extends BaseModel return $real_value; } + public function resolveReferencedValue(): ?string + { + $value = $this->value; + + if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) { + return $value; + } + + $referencedKey = str($value)->after('$')->trim('{}')->value(); + + return static::where('resourceable_type', $this->resourceable_type) + ->where('resourceable_id', $this->resourceable_id) + ->where('is_preview', (bool) $this->is_preview) + ->where('key', $referencedKey) + ->first()?->value ?? $value; + } + private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource); @@ -389,8 +413,6 @@ class EnvironmentVariable extends BaseModel protected function updateIsShared(): void { - $type = str($this->value)->after('{{')->before('.')->value; - $isShared = str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}'); - $this->is_shared = $isShared; + $this->is_shared = $this->isSharedReference(); } } diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 564fbcf6a4..96c7a2d39d 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -2,11 +2,14 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\DB; class GithubApp extends BaseModel { + use Auditable; + public function delete(): ?bool { return DB::transaction(fn () => parent::delete()); diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index c6c2b84095..727ec77cd1 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -2,12 +2,15 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\Crypt; class GitlabApp extends BaseModel { + use Auditable; + protected $fillable = [ 'name', 'organization', diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 26aceec354..1e7d8282a5 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -22,6 +22,7 @@ class InstanceSettings extends Model 'do_not_track', 'is_auto_update_enabled', 'is_registration_enabled', + 'disable_registration_when_oauth_enabled', 'next_channel', 'smtp_enabled', 'smtp_from_address', @@ -89,6 +90,8 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', + 'is_registration_enabled' => 'boolean', + 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', @@ -116,6 +119,19 @@ class InstanceSettings extends Model }); } + public function isPasswordRegistrationAllowed(): bool + { + if (! $this->is_registration_enabled) { + return false; + } + + if (! $this->disable_registration_when_oauth_enabled) { + return true; + } + + return ! OauthSetting::where('enabled', true)->exists(); + } + public function fqdn(): Attribute { return Attribute::make( diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php new file mode 100644 index 0000000000..25c2f55939 --- /dev/null +++ b/app/Models/IntegrationToken.php @@ -0,0 +1,96 @@ + 'Cloudflare', + 'doppler' => 'Doppler', + 'infisical' => 'Infisical', + 'vault' => 'HashiCorp Vault', + ]; + + protected $fillable = [ + 'team_id', + 'provider', + 'name', + 'token', + 'capabilities', + 'metadata', + ]; + + protected $hidden = [ + 'token', + ]; + + protected function casts(): array + { + return [ + 'token' => 'encrypted', + 'capabilities' => 'array', + 'metadata' => 'array', + ]; + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function secretManagerLinks(): HasMany + { + return $this->hasMany(SecretManagerLink::class); + } + + public function dnsZones(): HasMany + { + return $this->hasMany(DnsProviderZone::class); + } + + public function managedDnsRecords(): HasMany + { + return $this->hasMany(ManagedDnsRecord::class); + } + + public function isSecretManager(): bool + { + return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true); + } + + public function providerName(): string + { + return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); + } + + public function automaticDnsEnabled(): bool + { + return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false; + } + + public function dopplerTokenType(): ?string + { + if ($this->provider !== 'doppler') { + return null; + } + + return match (true) { + str_starts_with($this->token, 'dp.st.') => 'service', + str_starts_with($this->token, 'dp.sa.') => 'service_account', + default => null, + }; + } + + public static function ownedByCurrentTeam() + { + return self::query()->where('team_id', currentTeam()->id); + } +} diff --git a/app/Models/ManagedDnsRecord.php b/app/Models/ManagedDnsRecord.php new file mode 100644 index 0000000000..a025cce0cb --- /dev/null +++ b/app/Models/ManagedDnsRecord.php @@ -0,0 +1,32 @@ +belongsTo(DnsProviderZone::class, 'dns_provider_zone_id'); + } + + public function integrationToken(): BelongsTo + { + return $this->belongsTo(IntegrationToken::class); + } + + public function resource(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/OauthIdentity.php b/app/Models/OauthIdentity.php new file mode 100644 index 0000000000..1edf71ad2f --- /dev/null +++ b/app/Models/OauthIdentity.php @@ -0,0 +1,35 @@ + 'array', + 'last_login_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index e7999134a6..7765e41160 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -11,7 +11,19 @@ class OauthSetting extends Model { use HasFactory; - protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; + protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds']; + + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + 'allow_registration' => 'boolean', + 'auto_join_root_team' => 'boolean', + 'require_email_verified' => 'boolean', + 'use_pkce' => 'boolean', + 'clock_skew_seconds' => 'integer', + ]; + } protected $hidden = [ 'client_secret', @@ -32,9 +44,46 @@ class OauthSetting extends Model return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant); case 'authentik': case 'clerk': + case 'oidc': return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url); default: return filled($this->client_id) && filled($this->client_secret); } } + + /** + * @return array + */ + public function scopeList(): array + { + $scopes = str($this->scopes ?: 'openid email profile') + ->replace(',', ' ') + ->explode(' ') + ->map(fn (string $scope) => trim($scope)) + ->filter() + ->unique() + ->values() + ->all(); + + return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes; + } + + public function loginLabel(): string + { + if (filled($this->custom_label)) { + return $this->custom_label; + } + + $envLabel = config("services.{$this->provider}.custom_label"); + if (filled($envLabel)) { + return $envLabel; + } + + return __("auth.login.{$this->provider}"); + } + + public function isOidc(): bool + { + return $this->provider === 'oidc'; + } } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 3f72642a57..43aa310cbc 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -31,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { - use HasFactory, HasSafeStringAttribute, WithRateLimiting; + use Auditable, HasFactory, HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', diff --git a/app/Models/Project.php b/app/Models/Project.php index 57dbf823ce..65c21c1e78 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,8 +21,8 @@ use OpenApi\Attributes as OA; )] class Project extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ @@ -63,7 +64,9 @@ class Project extends BaseModel ]); }); static::deleting(function ($project) { - $project->environments()->delete(); + foreach ($project->environments()->get() as $environment) { + $environment->delete(); + } $project->settings()->delete(); $shared_variables = $project->environment_variables(); foreach ($shared_variables as $shared_variable) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e4b1e2fd68..3c0d9e7e95 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Validator; class S3Storage extends BaseModel { - use HasFactory, HasSafeStringAttribute; + use Auditable, HasFactory, HasSafeStringAttribute; private const CONNECTION_TIMEOUT_SECONDS = 15; diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php new file mode 100644 index 0000000000..34e4e90d12 --- /dev/null +++ b/app/Models/SecretManagerLink.php @@ -0,0 +1,122 @@ + 'array', + ]; + } + + public function resourceable(): MorphTo + { + return $this->morphTo(); + } + + public function integrationToken(): BelongsTo + { + return $this->belongsTo(IntegrationToken::class); + } + + /** + * Fetch the secrets from the remote manager. Values live only in memory. + * + * @return array + */ + public function fetchSecrets(): array + { + $token = $this->integrationToken; + $settings = $this->settings ?? []; + $metadata = $token->metadata ?? []; + + return match ($token->provider) { + 'doppler' => (new DopplerService($token->token))->fetchSecrets( + data_get($settings, 'project'), + data_get($settings, 'config'), + ), + 'infisical' => (new InfisicalService( + data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token->token, + ))->fetchSecrets( + (string) data_get($settings, 'project_id'), + (string) data_get($settings, 'environment'), + (string) data_get($settings, 'secret_path', '/'), + ), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token->token, + data_get($metadata, 'namespace'), + ))->fetchSecrets( + (string) data_get($settings, 'mount', 'secret'), + (string) data_get($settings, 'path'), + ), + default => throw new \RuntimeException("Unsupported secret manager provider [{$token->provider}]."), + }; + } + + /** + * Create one {{vault.KEY}} reference variable per remote key that has no + * variable with that key yet. Only key names touch the database. + * + * @return list The keys that were imported + */ + public function importMissingReferences(): array + { + $keys = array_keys($this->fetchSecrets()); + sort($keys); + + $existing = $this->resourceable->environment_variables()->pluck('key')->flip(); + $imported = []; + + foreach ($keys as $key) { + if (isset($existing[$key])) { + continue; + } + + $this->resourceable->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + $imported[] = $key; + } + + return $imported; + } + + /** Short human-readable description of the remote source for the UI. */ + public function sourceSummary(): string + { + $settings = $this->settings ?? []; + + return match ($this->integrationToken->provider) { + 'doppler' => trim(implode('/', array_filter([ + data_get($settings, 'project'), + data_get($settings, 'config'), + ])), '/') ?: 'token scope', + 'infisical' => data_get($settings, 'project_id').'/'.data_get($settings, 'environment').data_get($settings, 'secret_path', '/'), + 'vault' => data_get($settings, 'mount', 'secret').'/'.data_get($settings, 'path'), + default => '', + }; + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 6795c4ac90..15d790d5c9 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -21,6 +21,7 @@ use App\Services\DigitalOceanService; use App\Services\HetznerService; use App\Services\VultrService; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; @@ -111,7 +112,7 @@ use Symfony\Component\Yaml\Yaml; class Server extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; /** * Sentinel IP for servers that do not have a real address yet @@ -985,6 +986,11 @@ $siteAddress { return $this->settings->is_metrics_enabled; } + public function isTrafficAnalyticsEnabled(): bool + { + return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false); + } + public function isServerApiEnabled(): bool { return $this->settings->is_sentinel_enabled; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index c3fa8721c4..512247b771 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -27,6 +27,13 @@ use OpenApi\Attributes as OA; 'is_logdrain_highlight_enabled' => ['type' => 'boolean'], 'is_logdrain_newrelic_enabled' => ['type' => 'boolean'], 'is_metrics_enabled' => ['type' => 'boolean'], + 'is_traffic_analytics_enabled' => ['type' => 'boolean'], + 'traffic_topn' => ['type' => 'integer'], + 'traffic_sample_threshold' => ['type' => 'integer'], + 'traffic_retention_1h_days' => ['type' => 'integer'], + 'traffic_retention_1d_days' => ['type' => 'integer'], + 'is_geoip_enabled' => ['type' => 'boolean'], + 'geoip_refresh_days' => ['type' => 'integer'], 'is_reachable' => ['type' => 'boolean'], 'is_sentinel_enabled' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'], @@ -106,6 +113,14 @@ class ServerSetting extends Model 'backup_compression_cpu_percentage', 'disable_application_image_retention', 'connection_timeout', + 'is_traffic_analytics_enabled', + 'traffic_topn', + 'traffic_sample_threshold', + 'traffic_retention_1h_days', + 'traffic_retention_1d_days', + 'is_geoip_enabled', + 'geoip_refresh_days', + 'geoip_maxmind_license_key', 'docker_version', 'docker_version_checked_at', 'compose_version', @@ -123,6 +138,14 @@ class ServerSetting extends Model 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', 'connection_timeout' => 'integer', + 'is_traffic_analytics_enabled' => 'boolean', + 'traffic_topn' => 'integer', + 'traffic_sample_threshold' => 'integer', + 'traffic_retention_1h_days' => 'integer', + 'traffic_retention_1d_days' => 'integer', + 'is_geoip_enabled' => 'boolean', + 'geoip_refresh_days' => 'integer', + 'geoip_maxmind_license_key' => 'encrypted', 'docker_version_checked_at' => 'datetime', 'compose_version_checked_at' => 'datetime', 'backup_compression_cpu_percentage' => 'integer', @@ -140,6 +163,7 @@ class ServerSetting extends Model 'logdrain_axiom_api_key', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'geoip_maxmind_license_key', ]; protected static function booted() @@ -162,9 +186,21 @@ class ServerSetting extends Model $settings->wasChanged('sentinel_custom_url') || $settings->wasChanged('sentinel_metrics_refresh_rate_seconds') || $settings->wasChanged('sentinel_metrics_history_days') || - $settings->wasChanged('sentinel_push_interval_seconds') + $settings->wasChanged('sentinel_push_interval_seconds') || + $settings->wasChanged('traffic_topn') || + $settings->wasChanged('traffic_sample_threshold') || + $settings->wasChanged('traffic_retention_1h_days') || + $settings->wasChanged('traffic_retention_1d_days') || + $settings->wasChanged('is_geoip_enabled') || + $settings->wasChanged('geoip_refresh_days') || + $settings->wasChanged('geoip_maxmind_license_key') ) { - $settings->server->restartSentinel(); + // Only recreate Sentinel when it is already enabled. Otherwise a change to a + // traffic/geoip tuning knob would turn Sentinel on as a side effect, because + // StartSentinel unconditionally sets is_sentinel_enabled = true. + if ($settings->is_sentinel_enabled) { + $settings->server->restartSentinel(); + } } }); } diff --git a/app/Models/Service.php b/app/Models/Service.php index e963571cb4..6ed5e836f2 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -5,8 +5,11 @@ namespace App\Models; use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; use App\Support\DomainPortOverrides; +use App\Traits\Auditable; + use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -43,7 +46,7 @@ use Symfony\Component\Yaml\Yaml; )] class Service extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes; private static $parserVersion = '5'; @@ -1615,7 +1618,7 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $envs->push("{$env->key}={$env->real_value}"); + $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}"); } if ($envs->count() === 0) { $commands[] = "touch {$environmentFilename} && mv {$environmentFilename} .env"; diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index c70bf9f08a..086cc33e50 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -3,11 +3,14 @@ namespace App\Models; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class SharedEnvironmentVariable extends Model { + use Auditable; + protected $fillable = [ // Core identification 'key', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 7ca45cc3b7..6265345ee9 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,17 +2,21 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + + protected array $auditExclude = ['last_online_at']; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 769d9f00c4..da4804dd2d 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 15a1fe2f82..f4dbaec210 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 378d36395d..c923b489bd 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,10 +2,12 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -13,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 1010ca5f37..70b108087a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 90828bf012..6a08a4dc45 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e7db812858..f8dc5c0caa 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,17 +2,19 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 3262611903..3bfcc5434e 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,17 +2,21 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + + protected array $auditExclude = ['last_online_at']; protected $fillable = [ 'uuid', diff --git a/app/Models/Tag.php b/app/Models/Tag.php index d5cccabd8f..30844b2bb6 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -18,7 +19,7 @@ use OpenApi\Attributes as OA; )] class Tag extends BaseModel { - use HasSafeStringAttribute; + use Auditable, HasSafeStringAttribute; protected $fillable = [ 'name', diff --git a/app/Models/Team.php b/app/Models/Team.php index 4cf6391231..6ec79f2046 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; +use App\Traits\Auditable; use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -39,7 +40,7 @@ use OpenApi\Attributes as OA; class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; + use Auditable, HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $fillable = [ 'name', @@ -86,8 +87,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen } // Transfer instance-wide sources to root team so they remain available - GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); - GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); + $systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get() + ->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get()); + foreach ($systemWideSources as $source) { + $source->update(['team_id' => 0]); + } // Delete non-instance-wide sources owned by this team $teamSources = GithubApp::where('team_id', $team->id)->get() @@ -313,6 +317,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(CloudProviderToken::class); } + public function integrationTokens() + { + return $this->hasMany(IntegrationToken::class); + } + public function sources() { $sources = collect([]); diff --git a/app/Models/User.php b/app/Models/User.php index bb810b30fd..9f037bb917 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,6 +11,7 @@ use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notifiable; @@ -557,12 +558,26 @@ class User extends Authenticatable implements SendsEmail && Carbon::now()->lessThan($this->email_change_code_expires_at); } + public function oauthIdentities(): HasMany + { + return $this->hasMany(OauthIdentity::class); + } + + public function hasSsoIdentity(): bool + { + return $this->oauthIdentities()->exists(); + } + /** * Check if the user has a password set. - * OAuth users are created without passwords. */ public function hasPassword(): bool { return ! empty($this->password); } + + public function requiresPasswordConfirmation(): bool + { + return $this->hasPassword() && ! $this->hasSsoIdentity(); + } } diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php new file mode 100644 index 0000000000..309c8167f2 --- /dev/null +++ b/app/Policies/IntegrationTokenPolicy.php @@ -0,0 +1,34 @@ +isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function view(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function update(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function delete(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5856791662..e4d2b0a851 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Auth\Oidc\OidcDiscoveryService; +use App\Auth\Oidc\OidcTokenValidator; +use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; @@ -10,6 +13,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; +use Laravel\Socialite\Contracts\Factory as SocialiteFactory; use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider @@ -22,12 +26,11 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureCommands(); - $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - + $this->configureOidcSocialite(); } private function configureCommands(): void @@ -62,6 +65,24 @@ class AppServiceProvider extends ServiceProvider Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); } + private function configureOidcSocialite(): void + { + if (! $this->app->bound(SocialiteFactory::class)) { + return; + } + + $this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) { + return new OidcProvider( + $app['request'], + $app->make(OidcDiscoveryService::class), + $app->make(OidcTokenValidator::class), + '', + '', + '', + ); + }); + } + private function configureGitHubHttp(): void { Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { @@ -77,16 +98,5 @@ class AppServiceProvider extends ServiceProvider ])->baseUrl($api_url); } }); - - Http::macro('GitLab', function (string $api_url, ?string $access_token = null) { - $client = Http::withHeaders([ - 'Accept' => 'application/json', - ])->baseUrl($api_url); - if ($access_token) { - $client = $client->withToken($access_token); - } - - return $client; - }); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 09b2a3e089..e8e6fb42c6 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; +use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; @@ -52,6 +53,7 @@ use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; +use App\Policies\IntegrationTokenPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; @@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, + IntegrationToken::class => IntegrationTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, Tag::class => TagPolicy::class, diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php deleted file mode 100644 index 07e0e8709f..0000000000 --- a/app/Providers/DuskServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -visit('/login') - ->type('email', 'test@example.com') - ->type('password', 'password') - ->press('Login'); - }); - } -} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 60d2545a05..6426860187 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -46,7 +46,7 @@ class FortifyServiceProvider extends ServiceProvider $isFirstUser = User::count() === 0; $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { return redirect()->route('login'); } @@ -59,13 +59,13 @@ class FortifyServiceProvider extends ServiceProvider $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0) { - // If there are no users, redirect to registration + if ($users == 0 && $settings->isPasswordRegistrationAllowed()) { + // If there are no users and password registration is allowed, redirect to registration. return redirect()->route('register'); } return view('auth.login', [ - 'is_registration_enabled' => $settings->is_registration_enabled, + 'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(), 'enabled_oauth_providers' => $enabled_oauth_providers, ]); }); diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php new file mode 100644 index 0000000000..2ec8f88e3e --- /dev/null +++ b/app/Services/Auth/OauthLoginService.php @@ -0,0 +1,228 @@ +email)); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new HttpException(403, 'OAuth provider did not return a valid email address'); + } + + $user = $provider === 'oidc' + ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) + : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); + + Auth::login($user); + $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); + session(['currentTeam' => $user->currentTeam = $team]); + + return $user; + } + + private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $provider = $oauthSetting->provider; + $providerUserId = $oauthUser->id ?? null; + if ( + (! is_string($providerUserId) && ! is_int($providerUserId)) + || (is_string($providerUserId) && trim($providerUserId) === '') + ) { + throw new HttpException(403, 'OAuth provider did not return a valid user ID'); + } + $providerUserId = (string) $providerUserId; + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) + ? $oauthUser->issuer + : data_get($oauthUser->user, 'iss'); + $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) + ? $oauthUser->subject + : data_get($oauthUser->user, 'sub', $oauthUser->id); + $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified) + || data_get($oauthUser->user, 'email_verified') === true; + + if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') { + throw new HttpException(403, 'OIDC provider did not return issuer and subject claims'); + } + + if ($oauthSetting->require_email_verified && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider did not verify the email address'); + } + + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + + // Linking a new OIDC identity to an existing local account by email + // is account takeover unless the provider attests the email. This + // guard is independent of the require_email_verified toggle, which + // only governs the broader login flow. + if ($user && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account'); + } + + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function canCreateUser(OauthSetting $oauthSetting): bool + { + return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; + } + + private function createUser(string $name, string $email, OauthSetting $oauthSetting): User + { + if (User::count() === 0) { + $user = (new User)->forceFill([ + 'id' => 0, + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + $user->save(); + + $team = $user->teams()->first() ?? Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + + instanceSettings()->update(['is_registration_enabled' => false]); + + return $user; + } + + if ($oauthSetting->auto_join_root_team) { + return $this->createRootTeamOnlyUser($name, $email); + } + + return User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + } + + private function createRootTeamOnlyUser(string $name, string $email): User + { + return DB::transaction(function () use ($name, $email) { + $rootTeam = Team::find(0); + if ($rootTeam === null) { + throw new HttpException(403, 'Root team is not available for OAuth user provisioning'); + } + + $user = User::withoutEvents(fn () => User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ])); + + $user->teams()->attach($rootTeam, ['role' => 'member']); + + return $user; + }); + } +} diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php new file mode 100644 index 0000000000..2a4a761027 --- /dev/null +++ b/app/Services/CloudflareTokenValidator.php @@ -0,0 +1,42 @@ +client($token); + $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); + + if (! $verification->successful() || $verification->json('result.status') !== 'active') { + return false; + } + + if (in_array('dns', $capabilities, true)) { + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); + + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ + 'per_page' => 1, + ])->successful(); + } + + return true; + } + + private function client(string $token): PendingRequest + { + return Http::withToken($token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php new file mode 100644 index 0000000000..dab3599101 --- /dev/null +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -0,0 +1,77 @@ +destination->server; + if ($server->isNonRoot()) { + $commands = parseCommandsByLineForSudo(collect($commands), $server)->all(); + } + + $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction') + ? $database->resolvedSecretManagerValuesForRedaction() + : []; + $remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)); + + $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]); + $activity->save(); + + $process = Process::timeout(config('constants.ssh.command_timeout')) + ->idleTimeout(3600) + ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets): void { + $this->appendOutput($activity, $type, $this->redact($output, $secrets)); + }); + + $result = $process->wait(); + $status = $result->successful() ? ProcessStatus::FINISHED : ProcessStatus::ERROR; + $activity->properties = $activity->properties->merge([ + 'status' => $status->value, + 'exitCode' => $result->exitCode(), + ]); + $activity->save(); + + if (! $result->successful()) { + throw new \RuntimeException($this->redact($result->errorOutput(), $secrets), $result->exitCode()); + } + + return $activity; + } + + private function redact(string $value, array $secrets): string + { + foreach ($secrets as $secret) { + if (is_string($secret) && $secret !== '') { + $value = str_replace($secret, REDACTED, $value); + } + } + + return sanitize_utf8_text(remove_iip($value)); + } + + private function appendOutput(Activity $activity, string $type, string $output): void + { + if ($output === '') { + return; + } + + $entries = json_decode($activity->description ?: '[]', true, flags: JSON_THROW_ON_ERROR); + $entries[] = [ + 'type' => $type, + 'output' => $output, + 'timestamp' => hrtime(true), + 'batch' => 1, + 'order' => count($entries) + 1, + ]; + $activity->description = json_encode($entries, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $activity->save(); + } +} diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index e3ba77163d..184aa01eb3 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -170,6 +170,7 @@ class ApplicationConfigurationSnapshot $this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'), $this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'), $this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'), + $this->item('custom_container_name_prefix', 'Container name prefix', data_get($this->application, 'settings.custom_container_name_prefix'), 'redeploy'), $this->item('is_consistent_container_name_enabled', 'Consistent container name', data_get($this->application, 'settings.is_consistent_container_name_enabled'), 'redeploy'), $this->item('is_container_label_escape_enabled', 'Escape container labels', data_get($this->application, 'settings.is_container_label_escape_enabled'), 'redeploy'), $this->item('is_container_label_readonly_enabled', 'Read-only container labels', data_get($this->application, 'settings.is_container_label_readonly_enabled'), 'redeploy'), diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php new file mode 100644 index 0000000000..04470b3350 --- /dev/null +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -0,0 +1,188 @@ +> */ + private array $zoneCache = []; + + public function syncZones(IntegrationToken $token): int + { + unset($this->zoneCache[$token->team_id]); + $zones = []; + $page = 1; + do { + $response = $this->client($token)->get('https://api.cloudflare.com/client/v4/zones', ['page' => $page, 'per_page' => 50]); + if (! $response->successful() || $response->json('success') !== true) { + throw new RuntimeException('Cloudflare zones could not be synchronized.'); + } + array_push($zones, ...$response->json('result', [])); + $totalPages = max(1, (int) $response->json('result_info.total_pages', 1)); + $page++; + } while ($page <= $totalPages); + + DB::transaction(function () use ($token, $zones): void { + $ids = []; + foreach ($zones as $zone) { + $ids[] = $zone['id']; + $token->dnsZones()->updateOrCreate(['provider_zone_id' => $zone['id']], [ + 'name' => strtolower($zone['name']), 'account_id' => data_get($zone, 'account.id'), + 'account_name' => data_get($zone, 'account.name'), + ]); + } + $token->dnsZones()->whereNotIn('provider_zone_id', $ids)->whereDoesntHave('managedRecords')->delete(); + $metadata = $token->metadata ?? []; + $metadata['zones_synced_at'] = now()->toIso8601String(); + $token->update(['metadata' => $metadata]); + }); + + return count($zones); + } + + /** @return Collection */ + public function findZones(int $teamId, string $hostname): Collection + { + $hostname = strtolower(rtrim($hostname, '.')); + $matches = $this->zonesForTeam($teamId)->filter( + fn (DnsProviderZone $zone) => $hostname === $zone->name || str_ends_with($hostname, '.'.$zone->name) + ); + $longest = $matches->max(fn (DnsProviderZone $zone) => strlen($zone->name)); + + return $matches->filter(fn (DnsProviderZone $zone) => strlen($zone->name) === $longest)->values(); + } + + /** @return Collection */ + private function zonesForTeam(int $teamId): Collection + { + return $this->zoneCache[$teamId] ??= DnsProviderZone::query() + ->whereHas('integrationToken', fn ($query) => $query->where('team_id', $teamId)->where('provider', 'cloudflare')) + ->with('integrationToken') + ->get(); + } + + /** + * @return array{id: string, type: string, name: string, content: string}|null + */ + public function findRecord(DnsProviderZone $zone, string $hostname, string $type): ?array + { + $hostname = strtolower(rtrim($hostname, '.')); + $response = $this->client($zone->integrationToken)->get( + "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", + ['type' => $type, 'name' => $hostname, 'per_page' => 100], + ); + if (! $response->successful()) { + throw new RuntimeException('Cloudflare DNS records could not be checked.'); + } + $remote = collect($response->json('result', []))->first(); + if ($remote === null) { + return null; + } + + return [ + 'id' => (string) ($remote['id'] ?? ''), + 'type' => (string) ($remote['type'] ?? $type), + 'name' => strtolower((string) ($remote['name'] ?? $hostname)), + 'content' => (string) ($remote['content'] ?? ''), + ]; + } + + public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null): ManagedDnsRecord + { + $hostname = strtolower(rtrim($hostname, '.')); + $type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; + $remote = $this->findRecord($zone, $hostname, $type); + if ($remote !== null) { + if ($remote['content'] === $content) { + if ($remote['id'] === '') { + throw new RuntimeException('Cloudflare DNS records could not be checked.'); + } + + return $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + } + throw new DnsRecordConflictException($remote['id'], $remote['content'], $content); + } + $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [ + 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false, + ]); + if (! $response->successful() || ! is_string($response->json('result.id'))) { + throw new RuntimeException('Cloudflare could not create the DNS record.'); + } + + return $this->trackRecord($zone, $response->json('result.id'), $type, $hostname, $content, $resource); + } + + public function replaceRecord( + DnsProviderZone $zone, + string $recordId, + string $hostname, + string $content, + ?Model $resource = null, + ?string $expectedCurrent = null, + ): ManagedDnsRecord { + $hostname = strtolower(rtrim($hostname, '.')); + $type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; + $remote = $this->findRecord($zone, $hostname, $type); + if ($remote === null + || $remote['id'] === '' + || $remote['id'] !== $recordId + || ($expectedCurrent !== null && $remote['content'] !== $expectedCurrent) + || $remote['name'] !== $hostname) { + throw new RuntimeException('The DNS conflict is no longer available. Check the record again.'); + } + + $response = $this->client($zone->integrationToken)->put( + "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}", + ['type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false], + ); + if (! $response->successful()) { + throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.'); + } + + return $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource); + } + + public function deleteRecord(ManagedDnsRecord $record): bool + { + $record->loadMissing(['zone', 'integrationToken']); + $url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}"; + $response = $this->client($record->integrationToken)->get($url); + $remote = $response->json('result'); + if (! $response->successful() || ($remote['type'] ?? null) !== $record->type + || strtolower((string) ($remote['name'] ?? '')) !== $record->name || ($remote['content'] ?? null) !== $record->content) { + return false; + } + if (! $this->client($record->integrationToken)->delete($url)->successful()) { + return false; + } + $record->delete(); + + return true; + } + + private function trackRecord(DnsProviderZone $zone, string $recordId, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord + { + return ManagedDnsRecord::query()->updateOrCreate( + ['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $recordId], + ['team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id, + 'resource_type' => $resource?->getMorphClass(), 'resource_id' => $resource?->getKey(), + 'type' => $type, 'name' => $name, 'content' => $content], + ); + } + + private function client(IntegrationToken $token): PendingRequest + { + return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(10); + } +} diff --git a/app/Services/DopplerService.php b/app/Services/DopplerService.php new file mode 100644 index 0000000000..2513a4f7d8 --- /dev/null +++ b/app/Services/DopplerService.php @@ -0,0 +1,57 @@ +client()->get($this->baseUrl.'/v3/me')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Download all secrets for a config. Project and config are not needed for + * service tokens (the token itself is pinned to one config). + * + * @return array + */ + public function fetchSecrets(?string $project = null, ?string $config = null): array + { + $query = ['format' => 'json']; + if (filled($project)) { + $query['project'] = $project; + } + if (filled($config)) { + $query['config'] = $config; + } + + $response = $this->client()->get($this->baseUrl.'/v3/configs/config/secrets/download', $query); + + if (! $response->successful()) { + throw new \RuntimeException('Doppler API error: '.($response->json('messages.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json()) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + return Http::withToken($this->token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php new file mode 100644 index 0000000000..06f1e5d49f --- /dev/null +++ b/app/Services/InfisicalService.php @@ -0,0 +1,89 @@ + */ + private array $httpClientOptions; + + public function __construct(string $baseUrl, private string $clientId, private string $clientSecret) + { + $this->baseUrl = rtrim($baseUrl, '/'); + Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); + $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl); + } + + public function validate(): bool + { + try { + $this->login(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @return array + */ + public function fetchSecrets(string $projectId, string $environment, string $secretPath = '/'): array + { + $client = $this->client()->withToken($this->login()); + $secretPath = $secretPath ?: '/'; + + $response = $client->get($this->baseUrl.'/api/v4/secrets', [ + 'projectId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + + // Older self-hosted instances only expose the v3 endpoint. + if ($response->status() === 404) { + $response = $client->get($this->baseUrl.'/api/v3/secrets/raw', [ + 'workspaceId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + } + + if (! $response->successful()) { + throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('secrets', [])) + ->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')]) + ->all(); + } + + private function login(): string + { + $response = $this->client()->post($this->baseUrl.'/api/v1/auth/universal-auth/login', [ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + ]); + + $accessToken = $response->json('accessToken'); + if (! $response->successful() || blank($accessToken)) { + throw new \RuntimeException('Infisical login failed: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return $accessToken; + } + + private function client(): PendingRequest + { + return Http::acceptJson() + ->withOptions($this->httpClientOptions) + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php new file mode 100644 index 0000000000..6033ce98f7 --- /dev/null +++ b/app/Services/IntegrationTokenValidator.php @@ -0,0 +1,39 @@ + app(CloudflareTokenValidator::class)->validate($token, $capabilities), + 'doppler' => (new DopplerService($token))->validate(), + 'infisical' => (new InfisicalService( + (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token, + ))->validate(), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token, + data_get($metadata, 'namespace'), + ))->validate(), + default => false, + }; + } + + public function errorMessage(string $provider): string + { + return match ($provider) { + 'cloudflare' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.', + 'doppler' => 'The Doppler token could not be verified. Check the token and its access.', + 'infisical' => 'Infisical login failed. Check the base URL, the client ID, and the client secret.', + 'vault' => 'The Vault token could not be verified. Check the base URL, the namespace, and the token.', + default => 'The token could not be verified.', + }; + } +} diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php new file mode 100644 index 0000000000..3cd7eabe6c --- /dev/null +++ b/app/Services/SentinelTrafficClient.php @@ -0,0 +1,484 @@ + */ + private const ALLOWED_DIMENSIONS = [ + 'status', 'method', 'country', 'referer', 'browser', 'os', 'device', 'protocol', 'scheme', 'tls', 'cache', 'bot', 'agent', 'ip', 'useragent', + ]; + + public function __construct(protected Server $server) {} + + // NOTE: Sentinel's traffic API expects `from`/`to` as ISO-8601 Zulu strings + // (e.g. "2024-01-14T10:00:00Z"), confirmed against sentinel/API.md. + public function overview(?string $appKey, string $from, string $to): TrafficOverviewData + { + $json = json_decode($this->raw($this->overviewUrl($appKey, $from, $to)), true) ?? []; + + return TrafficOverviewData::fromSentinel($json); + } + + /** + * Convert a UI range key (24h/7d/30d) into ISO-8601 Zulu from/to bounds. + * + * @return array{0: string, 1: string} + */ + public static function rangeWindow(string $range): array + { + $to = now(); + $from = match ($range) { + '7d' => now()->subDays(7), + '30d' => now()->subDays(30), + default => now()->subDay(), + }; + + return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()]; + } + + /** + * Slim shared fetch for a single application's overview over a UI range, so the + * General-page widget and the full analytics tab don't duplicate window + client calls. + */ + public function appOverview(string $appKey, string $range = '24h'): TrafficOverviewData + { + [$from, $to] = self::rangeWindow($range); + + return $this->overview($appKey, $from, $to); + } + + public function paths(?string $appKey, string $from, string $to, int $limit = 50): Collection + { + $rows = json_decode($this->raw($this->pathsUrl($appKey, $from, $to, $limit)), true) ?? []; + + return collect($rows)->map(fn ($r) => TrafficPathData::fromSentinel($r)); + } + + public function breakdown(?string $appKey, string $dimension, string $from, string $to, int $limit = 50): Collection + { + $rows = json_decode($this->raw($this->breakdownUrl($appKey, $dimension, $from, $to, $limit)), true) ?? []; + + return collect($rows)->map(fn ($r) => TrafficBreakdownData::fromSentinel($r)); + } + + /** + * Per-bucket status-class time series for the stacked-area chart. + * + * The series endpoints take a single `range` knob (24h/7d/30d) rather than + * from/to, and always return a fixed-length, zero-filled array when present. + * An older Sentinel without the route answers 404 (empty/non-array body); + * we return an empty collection in that case so callers can gracefully fall + * back to the donut instead of surfacing an error. + * + * @return Collection + */ + public function series(?string $appKey, string $range = '24h'): Collection + { + $rows = json_decode($this->raw($this->seriesUrl($appKey, $range)), true); + + if (! is_array($rows) || $rows === []) { + return collect(); + } + + return collect($rows)->map(fn ($r) => TrafficSeriesBucketData::fromSentinel($r)); + } + + public function apps(): array + { + return json_decode($this->raw($this->appsUrl()), true) ?? []; + } + + public function attribution(): ?string + { + $json = json_decode($this->raw($this->attributionUrl()), true) ?? []; + + return data_get($json, 'attribution'); + } + + /** + * Warm the 60s response cache for every endpoint the dashboard reads, in as few SSH + * round-trips as possible. Prefers Sentinel's aggregate `/traffic/dashboard` (one call + * that returns every shape, including the per-app leaderboard), and falls back to a + * single batched `docker exec` over the individual endpoints when that route is absent + * (older Sentinel). Best-effort: any failure leaves the per-call methods to fetch + * individually. Returns the recorded app uuids so the caller can warm the per-app + * overviews when the fallback path is taken. + * + * @param array $dimensions + * @return array + */ + public function prefetchServerWide(?string $appKey, string $from, string $to, array $dimensions, string $range, int $pathLimit = 50, int $breakdownLimit = 50, int $appsLimit = 200): array + { + $bundle = $this->fetchDashboard($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit); + if ($bundle !== null) { + $this->seedFromDashboard($appKey, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle); + + if ($appKey !== null) { + return []; + } + + return array_values(array_filter( + array_map(fn ($app) => is_array($app) ? ($app['uuid'] ?? null) : null, $bundle['apps'] ?? []), + fn ($uuid) => is_string($uuid) && $uuid !== '' + )); + } + + // Fallback for older Sentinel without /traffic/dashboard: batch the individual endpoints. + $urls = [ + $this->overviewUrl($appKey, $from, $to), + $this->pathsUrl($appKey, $from, $to, $pathLimit), + $this->seriesUrl($appKey, $range), + $this->attributionUrl(), + ]; + foreach ($dimensions as $dimension) { + $urls[] = $this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit); + } + // The per-application leaderboard only exists on the unfiltered view. + if ($appKey === null) { + $urls[] = $this->appsUrl(); + } + + $this->warm($urls); + + if ($appKey !== null) { + return []; + } + + return array_values(array_filter( + $this->apps(), + fn ($uuid) => is_string($uuid) && $uuid !== '' + )); + } + + /** + * Fetch Sentinel's aggregate dashboard bundle, or null when the route is absent (older + * Sentinel 404s) or the response isn't a real bundle. The bundle always carries an + * `overview` member β€” even for an empty range β€” so its presence distinguishes a genuine + * response from a stub/`{}`. + * + * @return array|null + */ + private function fetchDashboard(?string $appKey, string $from, string $to, string $range, int $pathLimit, int $breakdownLimit, int $appsLimit): ?array + { + // Older Sentinel 404s this route. raw() throws on that (and doesn't cache the failure), + // so without a marker every refresh would re-probe over SSH before falling back to the + // batch. Remember the absence for the same 60s window as the data cache: at most one + // wasted probe per minute, and a Sentinel upgrade is picked up on the next window. + $absenceKey = 'traffic:dashboard-absent:'.$this->server->uuid; + if (Cache::get($absenceKey) === true) { + return null; + } + + try { + $decoded = json_decode($this->raw($this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit)), true); + } catch (\Throwable) { + Cache::put($absenceKey, true, 60); + + return null; + } + + if (! is_array($decoded) || ! array_key_exists('overview', $decoded)) { + Cache::put($absenceKey, true, 60); + + return null; + } + + return $decoded; + } + + /** + * Decompose the aggregate bundle back into the per-endpoint response cache, so the + * existing per-call methods (overview/paths/breakdown/series/attribution and each + * leaderboard app's overview) read it as a cache hit β€” the whole page from one fetch. + * + * @param array $dimensions + * @param array $bundle + */ + private function seedFromDashboard(?string $appKey, string $from, string $to, string $range, array $dimensions, int $pathLimit, int $breakdownLimit, array $bundle): void + { + $put = fn (string $url, $member) => Cache::put($this->cacheKey($url), json_encode($member), 60); + + $put($this->overviewUrl($appKey, $from, $to), $bundle['overview'] ?? []); + $put($this->pathsUrl($appKey, $from, $to, $pathLimit), $bundle['paths'] ?? []); + $put($this->seriesUrl($appKey, $range), $bundle['series'] ?? []); + $put($this->attributionUrl(), ['attribution' => $bundle['attribution'] ?? null]); + + $breakdowns = $bundle['breakdowns'] ?? []; + foreach ($dimensions as $dimension) { + $put($this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit), $breakdowns[$dimension] ?? []); + } + + foreach ($bundle['apps'] ?? [] as $app) { + $uuid = is_array($app) ? ($app['uuid'] ?? null) : null; + if (is_string($uuid) && $uuid !== '' && isset($app['overview'])) { + $put($this->overviewUrl($uuid, $from, $to), $app['overview']); + } + } + } + + /** + * Warm the per-app overview cache for the leaderboard in one batched exec. + * + * @param array $appKeys + */ + public function prefetchAppOverviews(array $appKeys, string $from, string $to): void + { + $urls = array_map(fn ($appKey) => $this->overviewUrl($appKey, $from, $to), $appKeys); + + $this->warm($urls); + } + + private function overviewUrl(?string $appKey, string $from, string $to): string + { + $path = $this->appScopedPath($appKey, 'overview'); + + return $this->url($path, ['from' => $from, 'to' => $to]); + } + + private function pathsUrl(?string $appKey, string $from, string $to, int $limit): string + { + $path = $this->appScopedPath($appKey, 'paths'); + + return $this->url($path, ['from' => $from, 'to' => $to, 'limit' => (int) $limit]); + } + + private function breakdownUrl(?string $appKey, string $dimension, string $from, string $to, int $limit): string + { + $this->assertSafeDimension($dimension); + $path = $this->appScopedPath($appKey, "breakdown/{$dimension}"); + + return $this->url($path, ['from' => $from, 'to' => $to, 'limit' => (int) $limit]); + } + + private function seriesUrl(?string $appKey, string $range): string + { + $range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $path = $this->appScopedPath($appKey, 'series'); + + return $this->url($path, ['range' => $range]); + } + + private function dashboardUrl(?string $appKey, string $from, string $to, string $range, int $pathLimit, int $breakdownLimit, int $appsLimit): string + { + $range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $query = [ + 'from' => $from, + 'to' => $to, + 'range' => $range, + 'paths_limit' => (int) $pathLimit, + 'breakdown_limit' => (int) $breakdownLimit, + ]; + if ($appKey === null) { + // apps_limit only applies to the server-wide leaderboard. + $query['apps_limit'] = (int) $appsLimit; + + return $this->url('/traffic/dashboard', $query); + } + $this->assertSafeKey($appKey); + + return $this->url("/app/{$appKey}/traffic/dashboard", $query); + } + + private function appsUrl(): string + { + return $this->url('/traffic/apps'); + } + + private function attributionUrl(): string + { + return $this->url('/traffic/attribution'); + } + + /** + * Build a traffic path, optionally scoped to a single (validated) app key. + */ + private function appScopedPath(?string $appKey, string $suffix): string + { + if ($appKey === null) { + return "/traffic/{$suffix}"; + } + $this->assertSafeKey($appKey); + + return "/app/{$appKey}/traffic/{$suffix}"; + } + + /** + * Reject anything that isn't a bare CUID2/UUID or hostname before it is + * interpolated into a shell-quoted `docker exec ... curl` command + * (see remoteFetch()/buildFetchCommand()). No quotes, spaces, slashes, or + * shell metacharacters. + */ + private function assertSafeKey(string $value): void + { + if ($value === '' || ! preg_match('/\A[A-Za-z0-9._:-]+\z/', $value)) { + throw new \InvalidArgumentException('Invalid traffic analytics app key.'); + } + } + + private function assertSafeDimension(string $dimension): void + { + if (! in_array($dimension, self::ALLOWED_DIMENSIONS, true)) { + throw new \InvalidArgumentException('Invalid traffic analytics dimension.'); + } + } + + private function url(string $path, array $query = []): string + { + // Colons in ISO-8601 Zulu timestamps are safe in a query string; keep them + // unencoded to match Sentinel's expected `from`/`to` format. + $qs = empty($query) ? '' : '?'.str_replace('%3A', ':', http_build_query($query)); + + return $this->base.$path.$qs; + } + + private function cacheKey(string $url): string + { + return 'traffic:'.$this->server->uuid.':'.md5($url); + } + + /** + * True when warm() may issue its batched exec: either raw() is the base (real transport), + * or a subclass has explicitly overridden batchRemoteFetch to intercept the batch. A fake + * that only overrides raw() returns false, so warm() stays off the wire. + */ + private function usesBatchableTransport(): bool + { + if ((new \ReflectionMethod($this, 'raw'))->getDeclaringClass()->getName() === self::class) { + return true; + } + + return (new \ReflectionMethod($this, 'batchRemoteFetch'))->getDeclaringClass()->getName() !== self::class; + } + + protected function raw(string $url): string + { + return Cache::remember($this->cacheKey($url), 60, fn () => $this->guard($this->remoteFetch($url))); + } + + /** + * Fetch several URLs in one `docker exec` and warm each one's response cache under the + * same key raw() reads, so the subsequent per-call methods become cache hits. Cache hits + * are skipped, individual error/invalid responses are left uncached (the per-call fetch + * surfaces them), and any transport failure is swallowed β€” warming is an optimization, + * never a correctness dependency. + * + * @param array $urls + */ + protected function warm(array $urls): void + { + // Batching only helps when raw() uses the real remote transport. A subclass that + // overrides raw() to serve canned bodies (a test fake) β€” but not batchRemoteFetch β€” + // would otherwise reach real SSH here; skip and let its raw() answer each call. + if (! $this->usesBatchableTransport()) { + return; + } + + $misses = array_values(array_filter($urls, fn ($url) => ! Cache::has($this->cacheKey($url)))); + if ($misses === []) { + return; + } + + try { + $output = $this->batchRemoteFetch($misses); + } catch (\Throwable) { + return; + } + + $bodies = explode(self::RECORD_SEPARATOR, $output); + foreach ($misses as $index => $url) { + $body = $bodies[$index] ?? ''; + try { + Cache::put($this->cacheKey($url), $this->guard($body), 60); + } catch (\Throwable) { + // Invalid/error body: leave uncached so raw() re-fetches and reports it. + } + } + } + + protected function remoteFetch(string $url): string + { + $token = $this->server->settings->ensureValidSentinelToken(); + + return instant_remote_process( + [$this->buildFetchCommand($token, $url)], + $this->server, + false + ); + } + + /** + * @param array $urls + */ + protected function batchRemoteFetch(array $urls): string + { + $token = $this->server->settings->ensureValidSentinelToken(); + + return instant_remote_process( + [$this->buildBatchCommand($token, $urls)], + $this->server, + false + ); + } + + /** + * Build the `docker exec ... curl` command run inside the Sentinel container. + * + * The URL is double-quoted inside the inner `sh -c` string so the literal `&` + * between the `from`/`to` (and `limit`) query params is not interpreted as a + * shell background operator β€” which would background curl after `from=...` and + * truncate every multi-param request. The app key and dimension are validated + * (assertSafeKey/assertSafeDimension) before reaching here, so the URL cannot + * contain shell metacharacters that break out of the quoting. + */ + protected function buildFetchCommand(string $token, string $url): string + { + return "docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" \"{$url}\"'"; + } + + /** + * Build one `docker exec` that curls every URL in order and separates the responses + * with a 0x1E record separator, so warm() can split them back apart. escapeshellarg + * safely wraps the whole script; each URL stays double-quoted so its `&` is literal. + * + * @param array $urls + */ + protected function buildBatchCommand(string $token, array $urls): string + { + $script = implode(' ; ', array_map( + fn ($url) => "curl -s -H \"Authorization: Bearer {$token}\" \"{$url}\" ; printf '\\036'", + $urls + )); + + return 'docker exec coolify-sentinel sh -c '.escapeshellarg($script); + } + + private function guard(string $response): string + { + $payload = json_decode($response, true); + + if (! is_array($payload)) { + throw new \RuntimeException('Traffic analytics returned an invalid response.'); + } + + if (array_key_exists('error', $payload)) { + $error = data_get($payload, 'error'); + throw new \RuntimeException(is_string($error) ? $error : 'Traffic analytics request failed.'); + } + + return $response; + } +} diff --git a/app/Services/TrafficAnalyticsAggregator.php b/app/Services/TrafficAnalyticsAggregator.php new file mode 100644 index 0000000000..6e057a3816 --- /dev/null +++ b/app/Services/TrafficAnalyticsAggregator.php @@ -0,0 +1,39 @@ + $overviews + * @return array{overview: TrafficOverviewData, latencyApproximate: bool, uniquesApproximate: bool} + */ + public static function sumOverviews(array $overviews): array + { + $multi = count($overviews) > 1; + $sum = fn (string $prop) => array_sum(array_map(fn ($o) => $o->{$prop}, $overviews)); + $max = fn (string $prop) => empty($overviews) ? 0.0 : max(array_map(fn ($o) => $o->{$prop}, $overviews)); + + $overview = new TrafficOverviewData( + requests: $sum('requests'), + bytesIn: $sum('bytesIn'), + bytesOut: $sum('bytesOut'), + s2xx: $sum('s2xx'), + s3xx: $sum('s3xx'), + s4xx: $sum('s4xx'), + s5xx: $sum('s5xx'), + latencyP50: (float) $max('latencyP50'), + latencyP95: (float) $max('latencyP95'), + latencyP99: (float) $max('latencyP99'), + uniqueVisitors: $sum('uniqueVisitors'), + ); + + return [ + 'overview' => $overview, + 'latencyApproximate' => $multi, + 'uniquesApproximate' => $multi, + ]; + } +} diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php new file mode 100644 index 0000000000..e41652cd54 --- /dev/null +++ b/app/Services/VaultService.php @@ -0,0 +1,68 @@ + */ + private array $httpClientOptions; + + public function __construct(string $baseUrl, private string $token, private ?string $namespace = null) + { + $this->baseUrl = rtrim($baseUrl, '/'); + Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); + $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl); + } + + public function validate(): bool + { + try { + return $this->client()->get($this->baseUrl.'/v1/auth/token/lookup-self')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Read a KV v2 secret. Non-string values are stored as JSON strings. + * + * @return array + */ + public function fetchSecrets(string $mount, string $path): array + { + $mount = trim($mount, '/'); + $path = trim($path, '/'); + + $response = $this->client()->get($this->baseUrl."/v1/{$mount}/data/{$path}"); + + if (! $response->successful()) { + throw new \RuntimeException('Vault API error: '.($response->json('errors.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('data.data', [])) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + $client = Http::withHeaders(['X-Vault-Token' => $this->token]) + ->acceptJson() + ->withOptions($this->httpClientOptions) + ->connectTimeout(5) + ->timeout(10); + + if (filled($this->namespace)) { + $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); + } + + return $client; + } +} diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php new file mode 100644 index 0000000000..311a4634a5 --- /dev/null +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -0,0 +1,111 @@ +databaseType($resource)) { + 'postgresql' => $dumpAll + ? 'psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} && (gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}' + : 'pg_restore --exit-on-error'.($replaceExisting ? ' --clean --if-exists' : '').' -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} '.$path, + 'mysql' => $dumpAll + ? $this->mysqlDumpAll('mysql', 'MYSQL', $path) + : '(gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE', + 'mariadb' => $dumpAll + ? $this->mysqlDumpAll('mariadb', 'MARIADB', $path) + : '(gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE', + 'mongodb' => 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='.$path, + default => throw new InvalidArgumentException('Database import is not supported for this database type.'), + }; + } + + public function buildPostgresRestoreScanScript(object $resource, string $path): ?string + { + if ($this->databaseType($resource) !== 'postgresql') { + return null; + } + + $escapedPath = escapeshellarg($path); + + // 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 {$escapedPath} 2>/dev/null || cat {$escapedPath}; }"; + $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 << "\$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; + } + + public function buildPostgresSafetyCommand(object $resource, string $container, string $path): ?string + { + $script = $this->buildPostgresRestoreScanScript($resource, $path); + + if ($script === null) { + return null; + } + + return 'docker exec '.$container.' sh -c '.escapeshellarg($script); + } + + public function supports(object $resource): bool + { + return in_array($this->databaseType($resource), ['postgresql', 'mysql', 'mariadb', 'mongodb'], true); + } + + public function databaseType(object $resource): string + { + $class = $resource->getMorphClass(); + $type = ($resource instanceof ServiceDatabase || str_contains(strtolower($class), 'service')) + ? strtolower($resource->databaseType()) + : strtolower($class); + + return match (true) { + str_contains($type, 'postgres') => 'postgresql', + str_contains($type, 'mariadb') => 'mariadb', + str_contains($type, 'mysql') => 'mysql', + str_contains($type, 'mongo') => 'mongodb', + default => 'unsupported', + }; + } + + private function mysqlDumpAll(string $binary, string $prefix, string $path): string + { + $rootPassword = '${'.$prefix.'_ROOT_PASSWORD}'; + $database = '${'.$prefix.'_DATABASE:-default}'; + + return "for pid in \$({$binary} -u root -p{$rootPassword} -N -e \"SELECT id FROM information_schema.processlist WHERE user != 'root';\"); do {$binary} -u root -p{$rootPassword} -e \"KILL \$pid\" 2>/dev/null || true; done && {$binary} -u root -p{$rootPassword} -N -e \"SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');\" | {$binary} -u root -p{$rootPassword} && {$binary} -u root -p{$rootPassword} -e \"CREATE DATABASE IF NOT EXISTS \\`{$database}\\`;\" && (gunzip -cf {$path} 2>/dev/null || cat {$path}) | {$binary} -u root -p{$rootPassword} {$database}"; + } +} diff --git a/app/Support/DatabaseImport/DatabaseImportException.php b/app/Support/DatabaseImport/DatabaseImportException.php new file mode 100644 index 0000000000..aeef2067ba --- /dev/null +++ b/app/Support/DatabaseImport/DatabaseImportException.php @@ -0,0 +1,13 @@ + Referenced secret key names (unique, in order of appearance) + */ + public static function referencedKeys(?string $value): array + { + if (blank($value)) { + return []; + } + + preg_match_all(self::PATTERN, $value, $matches); + + return array_values(array_unique($matches[1])); + } + + /** + * Replace every reference with its value from the secrets map. + * Keys missing from the map are left as-is β€” collect them first with + * missingKeys() and fail before calling substitute(). + * + * @param array $secrets + */ + public static function substitute(string $value, array $secrets): string + { + return preg_replace_callback( + self::PATTERN, + fn (array $matches) => array_key_exists($matches[1], $secrets) ? $secrets[$matches[1]] : $matches[0], + $value, + ); + } + + /** + * @param array $secrets + * @return list + */ + public static function missingKeys(?string $value, array $secrets): array + { + return array_values(array_filter( + self::referencedKeys($value), + fn (string $key) => ! array_key_exists($key, $secrets), + )); + } +} diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php new file mode 100644 index 0000000000..878d46c1e4 --- /dev/null +++ b/app/Traits/Auditable.php @@ -0,0 +1,102 @@ + $model->recordAuditMutation('created')); + static::updated(fn (Model $model) => $model->recordAuditMutation('updated')); + static::deleted(fn (Model $model) => $model->recordAuditMutation('deleted')); + } + + private function recordAuditMutation(string $action): void + { + if (! $this->auditLoggingEnabled || ! auth()->check()) { + return; + } + + $teamId = $this->auditTeamId(); + if ($teamId === null) { + return; + } + + $changedFields = $action === 'updated' + ? collect(array_keys($this->getChanges())) + ->reject(fn (string $field): bool => in_array($field, [ + 'updated_at', + 'order', + 'status', + ...($this->auditExclude ?? []), + ], true)) + ->values() + ->all() + : []; + + if ($action === 'updated' && $changedFields === []) { + return; + } + + $resourceType = Str::snake(class_basename($this)); + $source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui'; + + auditLog("{$source}.{$resourceType}.{$action}", [ + 'team_id' => $teamId, + "{$resourceType}_uuid" => $this->getAttribute('uuid'), + "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'), + 'changed_fields' => $changedFields, + ]); + } + + public function withoutAuditLogging(Closure $callback): mixed + { + $wasAuditLoggingEnabled = $this->auditLoggingEnabled; + $this->auditLoggingEnabled = false; + + try { + return $callback(); + } finally { + $this->auditLoggingEnabled = $wasAuditLoggingEnabled; + } + } + + private function auditTeamId(): ?int + { + if ($this instanceof Team) { + return (int) $this->getKey(); + } + + if ($this->getAttribute('team_id') !== null) { + return (int) $this->getAttribute('team_id'); + } + + if ($this->getAttribute('project_id') !== null) { + return $this->project?->team_id; + } + + if ($this->getAttribute('environment_id') !== null) { + return $this->environment?->project?->team_id; + } + + if ($this->getAttribute('server_id') !== null) { + return $this->server?->team_id; + } + + if ($this->getAttribute('resourceable_id') !== null) { + return $this->resourceable?->team()?->id + ?? $this->resourceable?->team_id + ?? $this->resourceable?->environment?->project?->team_id; + } + + return null; + } +} diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a2c3d06da9..b8ff5df14b 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -46,6 +46,13 @@ trait ExecuteRemoteCommand ); } + if (isset($this->remote_secrets_cache)) { + $lockedVars = $lockedVars->merge(array_values(array_filter( + $this->remote_secrets_cache, + static fn (mixed $value): bool => is_string($value) && $value !== '' + ))); + } + foreach ($lockedVars as $key => $value) { $escapedValue = preg_quote($value, '/'); $text = preg_replace( diff --git a/app/Traits/ExecutesDatabaseStartCommands.php b/app/Traits/ExecutesDatabaseStartCommands.php new file mode 100644 index 0000000000..d267a8b1b2 --- /dev/null +++ b/app/Traits/ExecutesDatabaseStartCommands.php @@ -0,0 +1,19 @@ +execute($commands, $database, $activity); + } + + return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + } +} diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php new file mode 100644 index 0000000000..8b3e50b7bd --- /dev/null +++ b/app/Traits/HasSecretManager.php @@ -0,0 +1,107 @@ +|null */ + private ?array $resolvedSecretManagerValues = null; + + public static function bootHasSecretManager(): void + { + static::deleting(fn ($resource) => $resource->secretManagerLink()->delete()); + } + + public function secretManagerLink(): MorphOne + { + return $this->morphOne(SecretManagerLink::class, 'resourceable'); + } + + public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + + return $this->formatEnvironmentVariableValue($environmentVariable, $value); + } + + public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string + { + if ($value === null) { + return null; + } + + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; + } + + return $environmentVariable->is_literal || $environmentVariable->is_multiline + ? "'{$value}'" + : escapeEnvVariables($value); + } + + public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolvedEnvironmentVariableValue($environmentVariable); + + if ($value === null) { + return null; + } + + if (RemoteSecretReferences::containsReference($value)) { + $secrets = $this->secretManagerValues(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key})."); + } + + $value = RemoteSecretReferences::substitute($value, $secrets); + } + + return $value; + } + + public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool + { + return RemoteSecretReferences::containsReference( + $this->resolvedEnvironmentVariableValue($environmentVariable), + ); + } + + private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string + { + return $environmentVariable->get_real_environment_variables_with_server( + $environmentVariable->value, + $this, + data_get($this, 'server'), + ); + } + + /** @return array */ + private function secretManagerValues(): array + { + if ($this->resolvedSecretManagerValues !== null) { + return $this->resolvedSecretManagerValues; + } + + $link = $this->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.'); + } + + return $this->resolvedSecretManagerValues = $link->fetchSecrets(); + } + + /** @return array */ + public function resolvedSecretManagerValuesForRedaction(): array + { + return $this->resolvedSecretManagerValues ?? []; + } +} diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php new file mode 100644 index 0000000000..1b46ca2dd5 --- /dev/null +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -0,0 +1,58 @@ +secretManagerLinkForAutocomplete() !== null; + } + + /** + * @return list + */ + public function fetchSecretManagerKeys(): array + { + $this->skipRender(); + + $link = $this->secretManagerLinkForAutocomplete(); + + if (! $link) { + return []; + } + + try { + $this->authorize('view', $link->resourceable); + $keys = array_keys($link->fetchSecrets()); + sort($keys); + + return $keys; + } catch (\Throwable) { + throw new \RuntimeException('Unable to fetch secret manager keys.'); + } + } + + private function secretManagerLinkForAutocomplete(): ?SecretManagerLink + { + $resource = $this->secretManagerResource(); + + if (! $resource || ! method_exists($resource, 'secretManagerLink')) { + return null; + } + + if (! $resource->relationLoaded('secretManagerLink')) { + $resource->load('secretManagerLink.integrationToken'); + } + + return $resource->secretManagerLink; + } +} diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index a3e6646fec..9ff5d72dc5 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -35,6 +35,7 @@ class EnvVarInput extends Component public mixed $canResource = null, public bool $autoDisable = true, public array $availableVars = [], + public bool $hasVaultSource = false, public ?string $projectUuid = null, public ?string $environmentUuid = null, public ?string $serverUuid = null, diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index b8001497ba..b32870a5f7 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -4,6 +4,7 @@ use App\Actions\Shared\MigrateResourceToDestination; use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Models\ApplicationSetting; use App\Models\Environment; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; @@ -141,6 +142,7 @@ function sharedDataApplications() 'gpu_options' => 'string|nullable', 'is_consistent_container_name_enabled' => 'boolean', 'custom_internal_name' => 'string|nullable', + 'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH, 'preview_url_template' => 'string', 'max_restart_count' => 'integer|min:0', 'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS, @@ -408,6 +410,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('gpu_options'); $request->offsetUnset('is_consistent_container_name_enabled'); $request->offsetUnset('custom_internal_name'); + $request->offsetUnset('custom_container_name_prefix'); $request->offsetUnset('docker_compose_raw'); $request->offsetUnset('tags'); } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 339a0bcf7b..2fb0bb3f53 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -84,6 +84,15 @@ function queue_application_deployment(Application $application, string $deployme 'only_this_server' => $only_this_server, ]); + if (auth()->check() && ! $is_webhook && ! $is_api && ! $rollback) { + auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid, + 'force_rebuild' => $force_rebuild, + ]); + } + if ($no_questions_asked) { $deployment->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index 8477450c4b..1a1ad0a994 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,13 +1,10 @@ $context Identifiers + outcome details. @@ -16,39 +13,15 @@ if (! function_exists('auditLog')) { function auditLog(string $event, array $context = [], string $level = 'info'): void { try { - $request = app()->bound('request') ? request() : null; - $user = auth()->check() ? auth()->user() : null; - $token = $user?->currentAccessToken(); - - $base = [ - 'event' => $event, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), - 'user_id' => $user?->id, - 'user_email' => $user?->email, - 'team_id' => $token ? data_get($token, 'team_id') : null, - 'token_id' => $token?->id ?? null, - 'token_name' => $token?->name ?? null, - 'method' => $request?->method(), - 'path' => $request?->path(), - ]; - - $payload = array_merge($base, $context); - - Log::channel('audit')->{$level}($event, $payload); - } catch (Throwable $e) { - // Audit logging must never break the request path. - try { - Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); - } catch (Throwable) { - } + AuditEvent::record($event, $context); + } catch (Throwable) { } } } if (! function_exists('auditLogWebhookFailure')) { /** - * Record a webhook signature/auth verification failure to the `audit` channel. + * Record a webhook signature/auth verification failure. */ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void { @@ -58,10 +31,7 @@ if (! function_exists('auditLogWebhookFailure')) { $event = "webhook.{$provider}.signature_failed"; $base = [ - 'event' => $event, 'reason' => $reason, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), 'method' => $request?->method(), 'path' => $request?->path(), 'event_header' => $request?->header('X-GitHub-Event') @@ -70,12 +40,8 @@ if (! function_exists('auditLogWebhookFailure')) { ?? $request?->header('X-Event-Key'), ]; - Log::channel('audit')->warning($event, array_merge($base, $context)); - } catch (Throwable $e) { - try { - Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); - } catch (Throwable) { - } + auditLog($event, array_merge($base, $context), 'warning'); + } catch (Throwable) { } } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 613a104e0e..3c87882d6f 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -349,17 +349,32 @@ function generateApplicationContainerName(Application $application, $pull_reques // TODO: refactor generateApplicationContainerName, we do not need $application and $pull_request_id $consistent_container_name = $application->settings->is_consistent_container_name_enabled; - $now = now()->format('Hisu'); + $name = $consistent_container_name ? ($application->settings->custom_internal_name ?: $application->uuid) : $application->uuid; + $now = now()->format('Ymd\THis'); if ($pull_request_id !== 0 && $pull_request_id !== null) { - return $application->uuid.'-pr-'.$pull_request_id; + return $name.'-pr-'.$pull_request_id; } else { if ($consistent_container_name) { - return $application->uuid; + return $name; } - return $application->uuid.'-'.$now; + return ($application->settings->custom_container_name_prefix ?: $application->uuid).'-'.$now; } } + +/** + * Generated (rolling update) container names end with the timestamp from generateApplicationContainerName(). + * Drop the legacy pattern once containers created before the ISO 8601 suffix are gone. + */ +function isGeneratedContainerName(string $containerName): bool +{ + $isoTimestampSuffix = '/-\d{8}T\d{6}$/'; + $legacyTimestampSuffix = '/-\d{12}$/'; + + return preg_match($isoTimestampSuffix, $containerName) === 1 + || preg_match($legacyTimestampSuffix, $containerName) === 1; +} + function get_port_from_dockerfile($dockerfile): ?int { $dockerfile_array = explode("\n", $dockerfile); @@ -530,7 +545,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, array $domainPortOverrides = []) +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = []) { $labels = collect([]); if ($serviceLabels) { @@ -596,6 +611,18 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($is_http_basic_auth_enabled) { $labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\""); } + if ($is_traffic_analytics_enabled) { + $labels->push("caddy_{$loop}.log.output=file /traffic/access.log"); + // Explicit lumberjack roll options so the access log doesn't grow unbounded + // (Caddy's defaults are undocumented). caddy-docker-proxy renders these dotted + // keys as a nested block: output file /traffic/access.log { roll_size 20MiB; roll_keep 5; roll_keep_for 168h }. + // Rotation is rename-based, which is safe for Sentinel's tailer (it reopens on inode change). + $labels->push("caddy_{$loop}.log.output.roll_size=20MiB"); + $labels->push("caddy_{$loop}.log.output.roll_keep=5"); + $labels->push("caddy_{$loop}.log.output.roll_keep_for=168h"); + $labels->push("caddy_{$loop}.log.format=json"); + $labels->push("caddy_{$loop}.log_append=coolify_app_id {$uuid}"); + } } return $labels->sort(); @@ -968,6 +995,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), domainPortOverrides: $application->domain_port_overrides ?? [], )); break; @@ -1001,6 +1029,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), domainPortOverrides: $application->domain_port_overrides ?? [], )); } @@ -1045,6 +1074,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; @@ -1076,6 +1106,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), domainPortOverrides: $preview->domain_port_overrides ?? [], )); } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index fe639950be..c5c0c391d0 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -8,6 +8,29 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Symfony\Component\Yaml\Yaml; +function traefikAccessLogCommands(bool $enabled): array +{ + if (! $enabled) { + return []; + } + + return [ + '--accesslog=true', + '--accesslog.filepath=/traefik/access.log', + '--accesslog.format=json', + '--accesslog.fields.headers.names.Cf-Connecting-Ip=keep', + '--accesslog.fields.headers.names.Cf-Ipcountry=keep', + '--accesslog.fields.headers.names.Cf-Cache-Status=keep', + '--accesslog.fields.headers.names.Cf-Verified-Bot=keep', + '--accesslog.fields.headers.names.Cf-Ray=keep', + // Kept so Sentinel can resolve the real client IP behind a non-Cloudflare + // reverse proxy (leftmost X-Forwarded-For entry) and report User-Agents/referrers. + '--accesslog.fields.headers.names.X-Forwarded-For=keep', + '--accesslog.fields.headers.names.User-Agent=keep', + '--accesslog.fields.headers.names.Referer=keep', + ]; +} + /** * Check if a network name is a Docker predefined system network. * These networks cannot be created, modified, or managed by docker network commands. @@ -325,13 +348,17 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command if (isDev()) { $config['services']['traefik']['command'][] = '--api.insecure=true'; $config['services']['traefik']['command'][] = '--log.level=debug'; - $config['services']['traefik']['command'][] = '--accesslog.filepath=/traefik/access.log'; $config['services']['traefik']['command'][] = '--accesslog.bufferingsize=100'; $config['services']['traefik']['volumes'][] = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/:/traefik'; } else { $config['services']['traefik']['command'][] = '--api.insecure=false'; $config['services']['traefik']['volumes'][] = "{$proxy_path}:/traefik"; } + // Access logging + analytics header capture (JSON log, real-IP/UA/referrer headers) + // applies to both dev and production so traffic analytics can be exercised locally. + foreach (traefikAccessLogCommands($server->isTrafficAnalyticsEnabled()) as $cmd) { + $config['services']['traefik']['command'][] = $cmd; + } if ($server->isSwarm()) { data_forget($config, 'services.traefik.container_name'); data_forget($config, 'services.traefik.restart'); @@ -358,6 +385,24 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command $config['services']['traefik']['command'][] = $custom_command; } } + + // Traefik has no native access-log rotation. Add a minimal logrotate sidecar that + // rotates /traefik/access.log in copytruncate mode so the file keeps the same inode + // and Sentinel keeps its file handle (the tailer handles len < pos by seeking to 0). + // Only for the non-swarm, non-dev production path (dev uses a different access-log path). + if ($server->isTrafficAnalyticsEnabled() && ! $server->isSwarm() && ! isDev()) { + $config['services']['traefik-logrotate'] = [ + 'image' => 'alpine:3.20', + 'restart' => RESTART_MODE, + 'volumes' => [ + "{$proxy_path}:/traefik", + ], + 'labels' => [ + 'coolify.managed=true', + ], + 'entrypoint' => 'sh -c \'apk add --no-cache logrotate >/dev/null 2>&1; printf "/traefik/access.log {\n copytruncate\n size 20M\n rotate 5\n compress\n missingok\n notifempty\n}\n" > /etc/logrotate.d/traefik-access; while true; do logrotate -s /traefik/.logrotate.state /etc/logrotate.d/traefik-access; sleep 3600; done\'', + ]; + } } elseif ($proxy_type === 'CADDY') { $config = [ 'networks' => $array_of_networks->toArray(), @@ -392,6 +437,9 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command ], ], ]; + if ($server->isTrafficAnalyticsEnabled()) { + $config['services']['caddy']['volumes'][] = "{$proxy_path}:/traffic"; + } } else { return null; } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index d1f5e4016b..7e10ded579 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4553,6 +4553,28 @@ function formatBytes(?int $bytes, int $precision = 2): string return round($value, $precision).' '.$units[$exponent]; } +/** + * Compact human-readable count (e.g. 26_360 -> "26.36k", 1_200_000 -> "1.2M"). + * Trailing zeros are trimmed so round values read cleanly ("1k", not "1.00k"). + * Used for the dense metric columns in the traffic-analytics lists. + */ +function compactNumber(?int $n): string +{ + $n = (int) $n; + + if ($n < 1000) { + return (string) $n; + } + + [$divisor, $suffix] = match (true) { + $n >= 1_000_000_000 => [1_000_000_000, 'B'], + $n >= 1_000_000 => [1_000_000, 'M'], + default => [1000, 'k'], + }; + + return rtrim(rtrim(number_format($n / $divisor, 2, '.', ''), '0'), '.').$suffix; +} + /** * Validates that a file path is safely within the /tmp/ directory. * Protects against unsafe parent directory paths by resolving the real path @@ -4691,7 +4713,7 @@ function formatContainerStatus(string $status): string * Check if password confirmation should be skipped. * Returns true if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * Used by modal-confirmation.blade.php to determine if password step should be shown. * @@ -4704,8 +4726,9 @@ function shouldSkipPasswordConfirmation(): bool return true; } - // Skip if user has no password (OAuth users) - if (! Auth::user()?->hasPassword()) { + // OAuth users may have an unusable generated password, so the linked + // identity is the source of truth for whether confirmation is possible. + if (! Auth::user()?->requiresPasswordConfirmation()) { return true; } @@ -4716,7 +4739,7 @@ function shouldSkipPasswordConfirmation(): bool * Verify password for two-step confirmation. * Skips verification if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * @param mixed $password The password to verify (may be array if skipped by frontend) * @param Component|null $component Optional Livewire component to add errors to @@ -4933,3 +4956,387 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string return str($value)->value(); } + +/** + * Convert an ISO 3166-1 alpha-2 country code into its regional-indicator flag emoji. + * + * The input is case-insensitive (e.g. "us" and "US" both yield the United States flag). + * For null, empty, or otherwise invalid input (not exactly two ASCII letters) a neutral + * globe emoji is returned to represent an "Unknown" origin. + */ +function countryFlagEmoji(?string $a2): string +{ + $unknown = '🌐'; + + if (! is_string($a2)) { + return $unknown; + } + + $code = strtoupper(trim($a2)); + + if (preg_match('/^[A-Z]{2}$/', $code) !== 1) { + return $unknown; + } + + $flag = ''; + foreach (str_split($code) as $letter) { + $flag .= mb_chr(0x1F1E6 + (ord($letter) - ord('A')), 'UTF-8'); + } + + return $flag; +} + +/** + * Resolve an ISO 3166-1 alpha-2 code to a flag image URL (flagcdn.com). + * + * Emoji flags do not render on most Linux/Windows browsers, so the analytics + * views render an instead. Returns null for null/invalid codes so callers + * can fall back to a globe icon. + */ +function countryFlagUrl(?string $a2, string $size = '24x18'): ?string +{ + if (! is_string($a2)) { + return null; + } + + $code = strtolower(trim($a2)); + + if (preg_match('/^[a-z]{2}$/', $code) !== 1) { + return null; + } + + return "https://flagcdn.com/{$size}/{$code}.png"; +} + +/** + * Extract the bare host from a referer value (full URL or bare host), dropping + * a leading "www.". Returns null when there is no usable host (e.g. direct hits). + */ +function refererHost(?string $referer): ?string +{ + if (! is_string($referer) || trim($referer) === '') { + return null; + } + + $referer = trim($referer); + $withScheme = str_contains($referer, '://') ? $referer : 'http://'.$referer; + $host = parse_url($withScheme, PHP_URL_HOST) ?: null; + + if (! $host) { + return null; + } + + $host = strtolower($host); + + return str_starts_with($host, 'www.') ? substr($host, 4) : $host; +} + +/** + * Favicon URL for a host, served by DuckDuckGo's icon proxy. Used to decorate + * referrer rows in analytics. + * + * Note: rendering these icons makes the operator's browser request each favicon + * from icons.duckduckgo.com, which discloses the referrer hostnames of the + * operator's own traffic to that third party. Same applies to countryFlagUrl() + * (flagcdn.com). No API key is required. + */ +function refererFaviconUrl(string $host): string +{ + return 'https://icons.duckduckgo.com/ip3/'.rawurlencode($host).'.ico'; +} + +/** + * Map Sentinel's lowercase woothee device category to a friendly, capitalized + * label (e.g. "pc" -> "Desktop", "smartphone" -> "Mobile"). + */ +function deviceLabel(?string $device): string +{ + $value = strtolower(trim((string) $device)); + + return match ($value) { + '' => 'Unknown', + 'pc' => 'Desktop', + 'smartphone' => 'Mobile', + 'mobilephone' => 'Mobile', + 'appliance' => 'Appliance', + 'crawler' => 'Bot', + default => Str::title($value), + }; +} + +/** + * Resolve an ISO 3166-1 alpha-2 country code to its English country name. + * + * Uses a bundled ISO 3166-1 lookup so the result is deterministic and does not + * depend on the intl extension being installed. Returns "Unknown" for null, + * empty, invalid, or unassigned codes. + */ +function countryName(?string $a2): string +{ + $unknown = 'Unknown'; + + if (! is_string($a2)) { + return $unknown; + } + + $code = strtoupper(trim($a2)); + + if (preg_match('/^[A-Z]{2}$/', $code) !== 1) { + return $unknown; + } + + static $names = [ + 'AD' => 'Andorra', + 'AE' => 'United Arab Emirates', + 'AF' => 'Afghanistan', + 'AG' => 'Antigua & Barbuda', + 'AI' => 'Anguilla', + 'AL' => 'Albania', + 'AM' => 'Armenia', + 'AO' => 'Angola', + 'AQ' => 'Antarctica', + 'AR' => 'Argentina', + 'AS' => 'American Samoa', + 'AT' => 'Austria', + 'AU' => 'Australia', + 'AW' => 'Aruba', + 'AX' => 'Γ…land Islands', + 'AZ' => 'Azerbaijan', + 'BA' => 'Bosnia & Herzegovina', + 'BB' => 'Barbados', + 'BD' => 'Bangladesh', + 'BE' => 'Belgium', + 'BF' => 'Burkina Faso', + 'BG' => 'Bulgaria', + 'BH' => 'Bahrain', + 'BI' => 'Burundi', + 'BJ' => 'Benin', + 'BL' => 'St. BarthΓ©lemy', + 'BM' => 'Bermuda', + 'BN' => 'Brunei', + 'BO' => 'Bolivia', + 'BQ' => 'Caribbean Netherlands', + 'BR' => 'Brazil', + 'BS' => 'Bahamas', + 'BT' => 'Bhutan', + 'BV' => 'Bouvet Island', + 'BW' => 'Botswana', + 'BY' => 'Belarus', + 'BZ' => 'Belize', + 'CA' => 'Canada', + 'CC' => 'Cocos (Keeling) Islands', + 'CD' => 'Congo - Kinshasa', + 'CF' => 'Central African Republic', + 'CG' => 'Congo - Brazzaville', + 'CH' => 'Switzerland', + 'CI' => 'CΓ΄te d’Ivoire', + 'CK' => 'Cook Islands', + 'CL' => 'Chile', + 'CM' => 'Cameroon', + 'CN' => 'China', + 'CO' => 'Colombia', + 'CR' => 'Costa Rica', + 'CU' => 'Cuba', + 'CV' => 'Cape Verde', + 'CW' => 'CuraΓ§ao', + 'CX' => 'Christmas Island', + 'CY' => 'Cyprus', + 'CZ' => 'Czechia', + 'DE' => 'Germany', + 'DJ' => 'Djibouti', + 'DK' => 'Denmark', + 'DM' => 'Dominica', + 'DO' => 'Dominican Republic', + 'DZ' => 'Algeria', + 'EC' => 'Ecuador', + 'EE' => 'Estonia', + 'EG' => 'Egypt', + 'EH' => 'Western Sahara', + 'ER' => 'Eritrea', + 'ES' => 'Spain', + 'ET' => 'Ethiopia', + 'FI' => 'Finland', + 'FJ' => 'Fiji', + 'FK' => 'Falkland Islands', + 'FM' => 'Micronesia', + 'FO' => 'Faroe Islands', + 'FR' => 'France', + 'GA' => 'Gabon', + 'GB' => 'United Kingdom', + 'GD' => 'Grenada', + 'GE' => 'Georgia', + 'GF' => 'French Guiana', + 'GG' => 'Guernsey', + 'GH' => 'Ghana', + 'GI' => 'Gibraltar', + 'GL' => 'Greenland', + 'GM' => 'Gambia', + 'GN' => 'Guinea', + 'GP' => 'Guadeloupe', + 'GQ' => 'Equatorial Guinea', + 'GR' => 'Greece', + 'GS' => 'South Georgia & South Sandwich Islands', + 'GT' => 'Guatemala', + 'GU' => 'Guam', + 'GW' => 'Guinea-Bissau', + 'GY' => 'Guyana', + 'HK' => 'Hong Kong SAR China', + 'HM' => 'Heard & McDonald Islands', + 'HN' => 'Honduras', + 'HR' => 'Croatia', + 'HT' => 'Haiti', + 'HU' => 'Hungary', + 'ID' => 'Indonesia', + 'IE' => 'Ireland', + 'IL' => 'Israel', + 'IM' => 'Isle of Man', + 'IN' => 'India', + 'IO' => 'British Indian Ocean Territory', + 'IQ' => 'Iraq', + 'IR' => 'Iran', + 'IS' => 'Iceland', + 'IT' => 'Italy', + 'JE' => 'Jersey', + 'JM' => 'Jamaica', + 'JO' => 'Jordan', + 'JP' => 'Japan', + 'KE' => 'Kenya', + 'KG' => 'Kyrgyzstan', + 'KH' => 'Cambodia', + 'KI' => 'Kiribati', + 'KM' => 'Comoros', + 'KN' => 'St. Kitts & Nevis', + 'KP' => 'North Korea', + 'KR' => 'South Korea', + 'KW' => 'Kuwait', + 'KY' => 'Cayman Islands', + 'KZ' => 'Kazakhstan', + 'LA' => 'Laos', + 'LB' => 'Lebanon', + 'LC' => 'St. Lucia', + 'LI' => 'Liechtenstein', + 'LK' => 'Sri Lanka', + 'LR' => 'Liberia', + 'LS' => 'Lesotho', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'LV' => 'Latvia', + 'LY' => 'Libya', + 'MA' => 'Morocco', + 'MC' => 'Monaco', + 'MD' => 'Moldova', + 'ME' => 'Montenegro', + 'MF' => 'St. Martin', + 'MG' => 'Madagascar', + 'MH' => 'Marshall Islands', + 'MK' => 'North Macedonia', + 'ML' => 'Mali', + 'MM' => 'Myanmar (Burma)', + 'MN' => 'Mongolia', + 'MO' => 'Macao SAR China', + 'MP' => 'Northern Mariana Islands', + 'MQ' => 'Martinique', + 'MR' => 'Mauritania', + 'MS' => 'Montserrat', + 'MT' => 'Malta', + 'MU' => 'Mauritius', + 'MV' => 'Maldives', + 'MW' => 'Malawi', + 'MX' => 'Mexico', + 'MY' => 'Malaysia', + 'MZ' => 'Mozambique', + 'NA' => 'Namibia', + 'NC' => 'New Caledonia', + 'NE' => 'Niger', + 'NF' => 'Norfolk Island', + 'NG' => 'Nigeria', + 'NI' => 'Nicaragua', + 'NL' => 'Netherlands', + 'NO' => 'Norway', + 'NP' => 'Nepal', + 'NR' => 'Nauru', + 'NU' => 'Niue', + 'NZ' => 'New Zealand', + 'OM' => 'Oman', + 'PA' => 'Panama', + 'PE' => 'Peru', + 'PF' => 'French Polynesia', + 'PG' => 'Papua New Guinea', + 'PH' => 'Philippines', + 'PK' => 'Pakistan', + 'PL' => 'Poland', + 'PM' => 'St. Pierre & Miquelon', + 'PN' => 'Pitcairn Islands', + 'PR' => 'Puerto Rico', + 'PS' => 'Palestinian Territories', + 'PT' => 'Portugal', + 'PW' => 'Palau', + 'PY' => 'Paraguay', + 'QA' => 'Qatar', + 'RE' => 'RΓ©union', + 'RO' => 'Romania', + 'RS' => 'Serbia', + 'RU' => 'Russia', + 'RW' => 'Rwanda', + 'SA' => 'Saudi Arabia', + 'SB' => 'Solomon Islands', + 'SC' => 'Seychelles', + 'SD' => 'Sudan', + 'SE' => 'Sweden', + 'SG' => 'Singapore', + 'SH' => 'St. Helena', + 'SI' => 'Slovenia', + 'SJ' => 'Svalbard & Jan Mayen', + 'SK' => 'Slovakia', + 'SL' => 'Sierra Leone', + 'SM' => 'San Marino', + 'SN' => 'Senegal', + 'SO' => 'Somalia', + 'SR' => 'Suriname', + 'SS' => 'South Sudan', + 'ST' => 'SΓ£o TomΓ© & PrΓ­ncipe', + 'SV' => 'El Salvador', + 'SX' => 'Sint Maarten', + 'SY' => 'Syria', + 'SZ' => 'Eswatini', + 'TC' => 'Turks & Caicos Islands', + 'TD' => 'Chad', + 'TF' => 'French Southern Territories', + 'TG' => 'Togo', + 'TH' => 'Thailand', + 'TJ' => 'Tajikistan', + 'TK' => 'Tokelau', + 'TL' => 'Timor-Leste', + 'TM' => 'Turkmenistan', + 'TN' => 'Tunisia', + 'TO' => 'Tonga', + 'TR' => 'TΓΌrkiye', + 'TT' => 'Trinidad & Tobago', + 'TV' => 'Tuvalu', + 'TW' => 'Taiwan', + 'TZ' => 'Tanzania', + 'UA' => 'Ukraine', + 'UG' => 'Uganda', + 'UM' => 'U.S. Outlying Islands', + 'US' => 'United States', + 'UY' => 'Uruguay', + 'UZ' => 'Uzbekistan', + 'VA' => 'Vatican City', + 'VC' => 'St. Vincent & Grenadines', + 'VE' => 'Venezuela', + 'VG' => 'British Virgin Islands', + 'VI' => 'U.S. Virgin Islands', + 'VN' => 'Vietnam', + 'VU' => 'Vanuatu', + 'WF' => 'Wallis & Futuna', + 'WS' => 'Samoa', + 'XK' => 'Kosovo', + 'YE' => 'Yemen', + 'YT' => 'Mayotte', + 'ZA' => 'South Africa', + 'ZM' => 'Zambia', + 'ZW' => 'Zimbabwe', + ]; + + return $names[$code] ?? $unknown; +} diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index fd3fbe74ba..f177e6c16f 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -1,7 +1,13 @@ client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -23,7 +29,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'authentik' || $provider == 'clerk') { - $authentik_clerk_config = new \SocialiteProviders\Manager\Config( + $authentik_clerk_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -34,7 +40,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'zitadel') { - $zitadel_config = new \SocialiteProviders\Manager\Config( + $zitadel_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -44,8 +50,12 @@ function get_socialite_provider(string $provider) return Socialite::driver('zitadel')->setConfig($zitadel_config); } + if ($provider === 'oidc') { + return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting)); + } + if ($provider == 'google') { - $google_config = new \SocialiteProviders\Manager\Config( + $google_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri @@ -63,11 +73,11 @@ function get_socialite_provider(string $provider) ]; $provider_class_map = [ - 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, - 'discord' => \SocialiteProviders\Discord\Provider::class, - 'github' => \Laravel\Socialite\Two\GithubProvider::class, - 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, - 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, + 'bitbucket' => BitbucketProvider::class, + 'discord' => Provider::class, + 'github' => GithubProvider::class, + 'gitlab' => GitlabProvider::class, + 'infomaniak' => SocialiteProviders\Infomaniak\Provider::class, ]; $socialite = Socialite::buildProvider( diff --git a/bun.lock b/bun.lock index cbe08fb954..8083b14d6b 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@tailwindcss/typography": "0.5.20", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "cobe": "^2.0.1", "playwright": "^1.58.2", "tw-animate-css": "^1.4.0", }, @@ -109,6 +110,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cobe": ["cobe@2.0.1", "", {}, "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], diff --git a/composer.json b/composer.json index 416b5e8f65..4778cc91dd 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": "^8.4", "danharrin/livewire-rate-limiting": "^2.2.1", "doctrine/dbal": "^4.4.4", + "firebase/php-jwt": "7.1.0", "guzzlehttp/guzzle": "^7.15.3", "laravel/fortify": "^1.37.3", "laravel/framework": "^12.65.0", @@ -63,7 +64,6 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.4.8", - "laravel/dusk": "^8.6.0", "laravel/pint": "^1.30.4", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", diff --git a/composer.lock b/composer.lock index e5718b31b0..55be2166b6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "971daeb1b3078a36428c0fb56bb895b7", + "content-hash": "13e5d201c34a64cdf53e80a21304c9d5", "packages": [ { "name": "aws/aws-crt-php", @@ -13698,80 +13698,6 @@ }, "time": "2026-05-19T20:09:50+00:00" }, - { - "name": "laravel/dusk", - "version": "v8.6.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zip": "*", - "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0|^8.0", - "symfony/finder": "^6.2|^7.0|^8.0", - "symfony/process": "^6.2|^7.0|^8.0", - "vlucas/phpdotenv": "^5.2" - }, - "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0|^13.0", - "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.1|^11.0|^12.0.1", - "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0|^8.0" - }, - "suggest": { - "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Dusk\\DuskServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Dusk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", - "keywords": [ - "laravel", - "testing", - "webdriver" - ], - "support": { - "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.6.0" - }, - "time": "2026-04-15T14:50:40+00:00" - }, { "name": "laravel/pint", "version": "v1.30.4", @@ -14817,72 +14743,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "php-webdriver/webdriver", - "version": "1.16.0", - "source": { - "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-zip": "*", - "php": "^7.3 || ^8.0", - "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "replace": { - "facebook/webdriver": "*" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.20.0", - "ondram/ci-detector": "^4.0", - "php-coveralls/php-coveralls": "^2.4", - "php-mock/php-mock-phpunit": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-simplexml": "For Firefox profile creation" - }, - "type": "library", - "autoload": { - "files": [ - "lib/Exception/TimeoutException.php" - ], - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", - "homepage": "https://github.com/php-webdriver/php-webdriver", - "keywords": [ - "Chromedriver", - "geckodriver", - "php", - "selenium", - "webdriver" - ], - "support": { - "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" - }, - "time": "2025-12-28T23:57:40+00:00" - }, { "name": "phpstan/phpstan", "version": "2.2.8", diff --git a/config/app.php b/config/app.php index 13a5b7d4b8..59aa6f4c28 100644 --- a/config/app.php +++ b/config/app.php @@ -193,8 +193,8 @@ return [ */ 'maintenance' => [ - 'driver' => 'cache', - 'store' => 'redis', + 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'), + 'store' => env('APP_MAINTENANCE_STORE', 'redis'), ], /* diff --git a/config/logging.php b/config/logging.php index 05cf8e13d3..89c9d38dde 100644 --- a/config/logging.php +++ b/config/logging.php @@ -133,13 +133,6 @@ return [ 'days' => 14, ], - 'audit' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/audit.log'), - 'level' => env('LOG_AUDIT_LEVEL', 'info'), - 'days' => env('LOG_AUDIT_DAYS', 90), - 'replace_placeholders' => true, - ], ], ]; diff --git a/config/services.php b/config/services.php index c5956cf6c9..3a2a0631ef 100644 --- a/config/services.php +++ b/config/services.php @@ -60,6 +60,14 @@ return [ 'tenant' => env('GOOGLE_TENANT'), ], + 'oidc' => [ + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect' => env('OIDC_REDIRECT_URI'), + 'base_url' => env('OIDC_BASE_URL'), + 'custom_label' => env('OIDC_LOGIN_LABEL'), + ], + 'zitadel' => [ 'client_id' => env('ZITADEL_CLIENT_ID'), 'client_secret' => env('ZITADEL_CLIENT_SECRET'), diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php new file mode 100644 index 0000000000..01ddebbd2b --- /dev/null +++ b/database/factories/AuditEventFactory.php @@ -0,0 +1,29 @@ + + */ +class AuditEventFactory extends Factory +{ + protected $model = AuditEvent::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'event' => 'ui.application.updated', + 'source' => 'ui', + 'action' => 'updated', + 'actor_type' => 'user', + 'description' => 'Application updated', + 'metadata' => [], + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/DnsProviderZoneFactory.php b/database/factories/DnsProviderZoneFactory.php new file mode 100644 index 0000000000..1b34aa5ffc --- /dev/null +++ b/database/factories/DnsProviderZoneFactory.php @@ -0,0 +1,20 @@ + IntegrationToken::factory(), 'provider_zone_id' => fake()->uuid(), + 'name' => fake()->unique()->domainName(), 'account_id' => fake()->uuid(), 'account_name' => fake()->company(), + ]; + } +} diff --git a/database/factories/IntegrationTokenFactory.php b/database/factories/IntegrationTokenFactory.php new file mode 100644 index 0000000000..b78f932947 --- /dev/null +++ b/database/factories/IntegrationTokenFactory.php @@ -0,0 +1,20 @@ + Team::factory(), 'provider' => 'cloudflare', 'name' => fake()->words(2, true), + 'token' => fake()->sha256(), 'capabilities' => ['dns'], + ]; + } +} diff --git a/database/factories/ManagedDnsRecordFactory.php b/database/factories/ManagedDnsRecordFactory.php new file mode 100644 index 0000000000..e4c163036d --- /dev/null +++ b/database/factories/ManagedDnsRecordFactory.php @@ -0,0 +1,22 @@ + DnsProviderZone::factory(), + 'integration_token_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integration_token_id, + 'team_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integrationToken->team_id, + 'provider_record_id' => fake()->uuid(), 'type' => 'A', 'name' => fake()->domainName(), 'content' => fake()->ipv4(), + ]; + } +} diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 19c4445b26..13fe6b6784 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -8,6 +8,12 @@ return new class extends Migration /** * The configuration snapshot/diff now store an encrypted blob (not valid * JSON), so the columns must hold arbitrary text instead of json. + * + * Coolify's own backend runs exclusively on PostgreSQL in production and + * SQLite in testing (see config/database.php β€” the only configured + * connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed + * resources, never Coolify's application database, so no driver path is + * needed for them here. */ public function up(): void { diff --git a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php new file mode 100644 index 0000000000..3160ef9ddb --- /dev/null +++ b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php @@ -0,0 +1,40 @@ +string('custom_label')->nullable(); + $table->string('scopes')->nullable(); + $table->boolean('allow_registration')->default(true); + $table->boolean('require_email_verified')->default(true); + $table->boolean('use_pkce')->default(true); + $table->unsignedSmallInteger('clock_skew_seconds')->default(60); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn([ + 'custom_label', + 'scopes', + 'allow_registration', + 'require_email_verified', + 'use_pkce', + 'clock_skew_seconds', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php new file mode 100644 index 0000000000..9f838e5779 --- /dev/null +++ b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('issuer'); + $table->string('provider_user_id'); + $table->string('email')->nullable()->index(); + $table->json('raw_claims')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + + $table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_identities'); + } +}; diff --git a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php new file mode 100644 index 0000000000..06c0f1dd52 --- /dev/null +++ b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('disable_registration_when_oauth_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('disable_registration_when_oauth_enabled'); + }); + } +}; diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php new file mode 100644 index 0000000000..b0f5aad18a --- /dev/null +++ b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php @@ -0,0 +1,28 @@ +boolean('auto_join_root_team')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn('auto_join_root_team'); + }); + } +}; diff --git a/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php b/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php new file mode 100644 index 0000000000..535469ab8b --- /dev/null +++ b/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php @@ -0,0 +1,44 @@ +boolean('is_traffic_analytics_enabled')->default(false); + $table->text('geoip_maxmind_license_key')->nullable(); + $table->integer('traffic_topn')->default(50); + $table->integer('traffic_sample_threshold')->default(0); + $table->integer('traffic_retention_1h_days')->default(30); + $table->integer('traffic_retention_1d_days')->default(395); + $table->boolean('is_geoip_enabled')->default(true); + $table->integer('geoip_refresh_days')->default(30); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn([ + 'is_traffic_analytics_enabled', + 'geoip_maxmind_license_key', + 'traffic_topn', + 'traffic_sample_threshold', + 'traffic_retention_1h_days', + 'traffic_retention_1d_days', + 'is_geoip_enabled', + 'geoip_refresh_days', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php new file mode 100644 index 0000000000..a17d3972d5 --- /dev/null +++ b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('name'); + $table->text('token'); + $table->json('capabilities'); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_tokens'); + } +}; diff --git a/database/migrations/2026_08_20_000000_create_audit_events_table.php b/database/migrations/2026_08_20_000000_create_audit_events_table.php new file mode 100644 index 0000000000..0ace21f229 --- /dev/null +++ b/database/migrations/2026_08_20_000000_create_audit_events_table.php @@ -0,0 +1,45 @@ +id(); + $table->unsignedBigInteger('team_id')->nullable(); + $table->string('event'); + $table->string('source', 32); + $table->string('action', 64); + $table->string('actor_type', 32); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('actor_email')->nullable(); + $table->unsignedBigInteger('actor_token_id')->nullable(); + $table->string('actor_token_name')->nullable(); + $table->string('resource_type')->nullable(); + $table->string('resource_uuid')->nullable(); + $table->string('resource_name')->nullable(); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 200)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index('created_at'); + $table->index(['team_id', 'created_at', 'id']); + $table->index(['team_id', 'action', 'created_at', 'id']); + $table->index(['team_id', 'source', 'created_at', 'id']); + $table->index(['team_id', 'resource_type', 'resource_uuid', 'created_at']); + $table->index(['team_id', 'actor_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_events'); + } +}; diff --git a/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php new file mode 100644 index 0000000000..744697628f --- /dev/null +++ b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php @@ -0,0 +1,36 @@ +json('metadata')->nullable()->after('capabilities'); + }); + + Schema::create('secret_manager_links', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('resourceable_type'); + $table->unsignedBigInteger('resourceable_id'); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->unique(['resourceable_type', 'resourceable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('secret_manager_links'); + + Schema::table('integration_tokens', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; diff --git a/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php b/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php new file mode 100644 index 0000000000..690e535311 --- /dev/null +++ b/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->string('provider_zone_id'); + $table->string('name'); + $table->string('account_id')->nullable(); + $table->string('account_name')->nullable(); + $table->timestamps(); + $table->unique(['integration_token_id', 'provider_zone_id']); + $table->index('name'); + }); + } + + public function down(): void + { + Schema::dropIfExists('dns_provider_zones'); + } +}; diff --git a/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php b/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php new file mode 100644 index 0000000000..6fbcba6089 --- /dev/null +++ b/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->foreignId('dns_provider_zone_id')->constrained()->cascadeOnDelete(); + $table->nullableMorphs('resource'); + $table->string('provider_record_id'); + $table->string('type', 16); + $table->string('name'); + $table->string('content'); + $table->timestamps(); + $table->unique(['dns_provider_zone_id', 'provider_record_id']); + $table->index(['team_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('managed_dns_records'); + } +}; diff --git a/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php b/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php new file mode 100644 index 0000000000..30acb09200 --- /dev/null +++ b/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php @@ -0,0 +1,21 @@ +whereNotNull('custom_internal_name') + ->where('custom_internal_name', '!=', '') + ->where('is_consistent_container_name_enabled', false) + ->update(['is_consistent_container_name_enabled' => true]); + } +}; diff --git a/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php b/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php new file mode 100644 index 0000000000..d49c1d57aa --- /dev/null +++ b/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php @@ -0,0 +1,18 @@ +string('custom_container_name_prefix')->nullable(); + }); + } +}; diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index 2e3e63defd..f916c4a9cd 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder 'github', 'gitlab', 'google', + 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 0d13dc18f1..c2762f1d85 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -28,7 +28,7 @@ function normalizeShellArgument(argument) { } export function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /); if (!sshCommandMatch) return []; const argsString = sshCommandMatch[1]; diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 21625eece4..e9acda3270 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -63,6 +63,14 @@ test('extractSshArgs preserves proxy command as a single normalized ssh option v assert.equal(sshArgs[4], 'root@example.com'); }); +test('extractSshArgs supports the generated bash or sh fallback command', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); diff --git a/lang/de.json b/lang/de.json index 7c43300e67..cbc2237a75 100644 --- a/lang/de.json +++ b/lang/de.json @@ -7,6 +7,7 @@ "auth.login.github": "Mit GitHub anmelden", "auth.login.gitlab": "Mit GitLab anmelden", "auth.login.google": "Mit Google anmelden", + "auth.login.oidc": "Mit SSO anmelden", "auth.login.infomaniak": "Mit Infomaniak anmelden", "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", diff --git a/lang/en.json b/lang/en.json index 12c21b6665..b97a10d629 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,6 +8,7 @@ "auth.login.github": "Login with GitHub", "auth.login.gitlab": "Login with Gitlab", "auth.login.google": "Login with Google", + "auth.login.oidc": "Login with SSO", "auth.login.infomaniak": "Login with Infomaniak", "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", diff --git a/lang/pl.json b/lang/pl.json index bcd8e23937..b05437ac4e 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -8,6 +8,7 @@ "auth.login.github": "Zaloguj siΔ™ przez GitHub", "auth.login.gitlab": "Zaloguj siΔ™ przez Gitlab", "auth.login.google": "Zaloguj siΔ™ przez Google", + "auth.login.oidc": "Zaloguj siΔ™ przez SSO", "auth.login.infomaniak": "Zaloguj siΔ™ przez Infomaniak", "auth.login.zitadel": "Zaloguj siΔ™ przez Zitadel", "auth.already_registered": "JuΕΌ zarejestrowany?", diff --git a/openapi.json b/openapi.json index 718191d3cf..6695b46132 100644 --- a/openapi.json +++ b/openapi.json @@ -11,6 +11,66 @@ } ], "paths": { + "\/applications\/{uuid}\/secret-manager": { + "patch": { + "tags": [ + "Secret Managers" + ], + "summary": "Configure Application Secret Manager", + "description": "Configure the secret manager source used by an application.", + "operationId": "configure-application-secret-manager", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "integration_token_uuid" + ], + "properties": { + "integration_token_uuid": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Secret manager configured." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications": { "get": { "tags": [ @@ -3508,6 +3568,175 @@ ] } }, + "\/applications\/{uuid}\/previews\/{pull_request_id}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Preview Deployment", + "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.", + "operationId": "delete-preview-deployment-by-pull-request-id", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pull_request_id", + "in": "path", + "description": "Pull request ID of the preview to delete.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Preview deletion queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Preview Domains", + "description": "Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.", + "operationId": "update-preview-domains-by-pull-request-id", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pull_request_id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "domains": { + "type": [ + "string", + "null" + ], + "example": "https:\/\/pr.example.com:3000" + }, + "docker_compose_domains": { + "type": [ + "array", + "null" + ], + "items": { + "properties": { + "name": { + "type": "string" + }, + "domain": { + "type": [ + "string", + "null" + ] + }, + "redirect": { + "type": [ + "string", + "null" + ], + "enum": [ + "www", + "non-www", + "both" + ] + } + }, + "type": "object" + } + }, + "force_domain_override": { + "type": "boolean", + "default": false + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Preview domains updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "$ref": "#\/components\/responses\/403" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflict." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications\/{uuid}\/envs": { "get": { "tags": [ @@ -4568,70 +4797,6 @@ ] } }, - "\/applications\/{uuid}\/previews\/{pull_request_id}": { - "delete": { - "tags": [ - "Applications" - ], - "summary": "Delete Preview Deployment", - "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.", - "operationId": "delete-preview-deployment-by-pull-request-id", - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "UUID of the application.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "pull_request_id", - "in": "path", - "description": "Pull request ID of the preview to delete.", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Preview deletion queued.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string" - } - }, - "type": "object" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "400": { - "$ref": "#\/components\/responses\/400" - }, - "404": { - "$ref": "#\/components\/responses\/404" - }, - "422": { - "$ref": "#\/components\/responses\/422" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "\/applications\/{uuid}\/tags": { "get": { "tags": [ @@ -5778,6 +5943,134 @@ ] } }, + "\/databases\/{uuid}\/imports\/uploads": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Upload database import", + "operationId": "upload-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Upload completed" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/imports": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Import database backup", + "operationId": "create-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/DatabaseImportRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Import queued" + }, + "409": { + "description": "Import already active" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/imports\/{activity_id}": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get database import status", + "operationId": "get-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "activity_id", + "in": "path", + "description": "Import activity ID.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Import status", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/DatabaseImportStatus" + } + } + } + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases": { "get": { "tags": [ @@ -5946,6 +6239,13 @@ "type": "integer", "description": "Backup job timeout in seconds (min: 60, max: 36000)", "default": 3600 + }, + "missing_backup_notification_days": { + "type": "integer", + "description": "Alert after this many days without an execution; 0 disables alerts", + "minimum": 0, + "maximum": 365, + "default": 0 } }, "type": "object" @@ -6545,6 +6845,12 @@ "type": "integer", "description": "Backup job timeout in seconds (min: 60, max: 36000)", "default": 3600 + }, + "missing_backup_notification_days": { + "type": "integer", + "description": "Alert after this many days without an execution; 0 disables alerts", + "minimum": 0, + "maximum": 365 } }, "type": "object" @@ -11689,13 +11995,129 @@ ] } }, + "\/settings\/email": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get instance email settings", + "description": "Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.", + "operationId": "get-instance-email-settings", + "responses": { + "200": { + "description": "Instance email settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Settings" + ], + "summary": "Update instance email settings", + "description": "Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.", + "operationId": "update-instance-email-settings", + "responses": { + "200": { + "description": "Updated instance email settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/security\/integration-tokens": { + "post": { + "tags": [ + "Secret Managers" + ], + "summary": "Create Secret Manager Token", + "description": "Create and validate a Doppler, Infisical, or Vault integration token.", + "operationId": "create-secret-manager-integration-token", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "provider", + "name", + "token" + ], + "properties": { + "provider": { + "type": "string", + "enum": [ + "doppler", + "infisical", + "vault" + ] + }, + "name": { + "type": "string" + }, + "token": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Integration token created." + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/notifications\/email": { "get": { "tags": [ "Notifications" ], "summary": "Get email notification settings", - "description": "Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", + "description": "Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", "operationId": "get-current-team-email-notifications", "responses": { "200": { @@ -11719,7 +12141,7 @@ "Notifications" ], "summary": "Update email notification settings", - "description": "Update the current team email notification settings.", + "description": "Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.", "operationId": "update-current-team-email-notifications", "responses": { "200": { @@ -15563,6 +15985,28 @@ "string", "null" ] + }, + "traffic_topn": { + "type": "integer" + }, + "traffic_sample_threshold": { + "type": "integer" + }, + "traffic_retention_1h_days": { + "type": "integer" + }, + "traffic_retention_1d_days": { + "type": "integer" + }, + "is_geoip_enabled": { + "type": "boolean" + }, + "geoip_refresh_days": { + "type": "integer" + }, + "geoip_maxmind_license_key": { + "description": "Only present with read:sensitive.", + "type": "string" } }, "type": "object" @@ -15639,6 +16083,35 @@ "string", "null" ] + }, + "traffic_topn": { + "type": "integer", + "minimum": 1 + }, + "traffic_sample_threshold": { + "type": "integer", + "minimum": 0 + }, + "traffic_retention_1h_days": { + "type": "integer", + "minimum": 1 + }, + "traffic_retention_1d_days": { + "type": "integer", + "minimum": 1 + }, + "is_geoip_enabled": { + "type": "boolean" + }, + "geoip_refresh_days": { + "type": "integer", + "minimum": 1 + }, + "geoip_maxmind_license_key": { + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -16824,6 +17297,12 @@ "null" ], "minimum": 0 + }, + "is_force_https_enabled": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -17211,6 +17690,161 @@ ] } }, + "\/services\/{uuid}\/databases\/{database_uuid}\/imports\/uploads": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Upload service database import", + "operationId": "upload-service-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Upload completed" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/imports": { + "post": { + "tags": [ + "Service databases" + ], + "summary": "Import service database backup", + "operationId": "create-service-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/DatabaseImportRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Import queued" + }, + "409": { + "description": "Import already active" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/databases\/{database_uuid}\/imports\/{activity_id}": { + "get": { + "tags": [ + "Service databases" + ], + "summary": "Get service database import status", + "operationId": "get-service-database-import", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "database_uuid", + "in": "path", + "description": "Service database UUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "activity_id", + "in": "path", + "description": "Import activity ID.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Import status", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/DatabaseImportStatus" + } + } + } + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services\/{uuid}\/databases": { "get": { "tags": [ @@ -21410,6 +22044,145 @@ }, "components": { "schemas": { + "DatabaseImportRequest": { + "type": "object", + "oneOf": [ + { + "required": [ + "source", + "upload_id" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "upload" + ] + }, + "upload_id": { + "type": "string", + "format": "uuid" + }, + "dump_all": { + "type": "boolean", + "default": false + }, + "replace_existing": { + "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", + "type": "boolean", + "default": false + } + }, + "type": "object", + "additionalProperties": false + }, + { + "required": [ + "source", + "s3_storage_uuid", + "path" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "s3" + ] + }, + "s3_storage_uuid": { + "type": "string" + }, + "path": { + "type": "string" + }, + "dump_all": { + "type": "boolean", + "default": false + }, + "replace_existing": { + "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", + "type": "boolean", + "default": false + } + }, + "type": "object", + "additionalProperties": false + }, + { + "required": [ + "source", + "path" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "server" + ] + }, + "path": { + "type": "string", + "example": "\/var\/backups\/database.sql.gz" + }, + "dump_all": { + "type": "boolean", + "default": false + }, + "replace_existing": { + "description": "Drop matching PostgreSQL objects before restoring a single-database archive.", + "type": "boolean", + "default": false + } + }, + "type": "object", + "additionalProperties": false + } + ] + }, + "DatabaseImportStatus": { + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "in_progress", + "finished", + "error", + "killed", + "cancelled", + "closed" + ] + }, + "exit_code": { + "type": [ + "integer", + "null" + ] + }, + "output": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "type": "object" + }, "VolumeBackupScheduleRequest": { "required": [ "frequency" @@ -21482,7 +22255,7 @@ }, "timeout": { "type": "integer", - "default": 3600, + "default": 36000, "maximum": 36000, "minimum": 60 } @@ -22528,6 +23301,9 @@ "deployment_queue_limit": { "type": "integer" }, + "backup_compression_cpu_percentage": { + "type": "integer" + }, "dynamic_timeout": { "type": "integer" }, @@ -22561,6 +23337,27 @@ "is_metrics_enabled": { "type": "boolean" }, + "is_traffic_analytics_enabled": { + "type": "boolean" + }, + "traffic_topn": { + "type": "integer" + }, + "traffic_sample_threshold": { + "type": "integer" + }, + "traffic_retention_1h_days": { + "type": "integer" + }, + "traffic_retention_1d_days": { + "type": "integer" + }, + "is_geoip_enabled": { + "type": "boolean" + }, + "geoip_refresh_days": { + "type": "integer" + }, "is_reachable": { "type": "boolean" }, @@ -22638,6 +23435,26 @@ "connection_timeout": { "type": "integer", "description": "SSH connection timeout in seconds." + }, + "docker_version": { + "type": "string", + "nullable": true, + "description": "Detected Docker Engine version on the server." + }, + "docker_version_checked_at": { + "type": "string", + "nullable": true, + "description": "When Docker Engine version was last detected." + }, + "compose_version": { + "type": "string", + "nullable": true, + "description": "Detected Docker Compose plugin version on the server." + }, + "compose_version_checked_at": { + "type": "string", + "nullable": true, + "description": "When Docker Compose version was last detected." } }, "type": "object" @@ -22977,6 +23794,10 @@ } }, "tags": [ + { + "name": "Secret Managers", + "description": "Secret Managers" + }, { "name": "Applications", "description": "Applications" @@ -23017,6 +23838,10 @@ "name": "Hetzner", "description": "Hetzner" }, + { + "name": "Settings", + "description": "Settings" + }, { "name": "Notifications", "description": "Notifications" diff --git a/openapi.yaml b/openapi.yaml index a53ab1439a..53d98109ec 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7,6 +7,45 @@ servers: url: 'https://app.coolify.io/api/v1' description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.' paths: + '/applications/{uuid}/secret-manager': + patch: + tags: + - 'Secret Managers' + summary: 'Configure Application Secret Manager' + description: 'Configure the secret manager source used by an application.' + operationId: configure-application-secret-manager + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - integration_token_uuid + properties: + integration_token_uuid: + type: string + settings: + type: object + type: object + responses: + '200': + description: 'Secret manager configured.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /applications: get: tags: @@ -2307,6 +2346,99 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/previews/{pull_request_id}': + delete: + tags: + - Applications + summary: 'Delete Preview Deployment' + description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.' + operationId: delete-preview-deployment-by-pull-request-id + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: pull_request_id + in: path + description: 'Pull request ID of the preview to delete.' + required: true + schema: + type: integer + responses: + '200': + description: 'Preview deletion queued.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Applications + summary: 'Update Preview Domains' + description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.' + operationId: update-preview-domains-by-pull-request-id + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: pull_request_id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + properties: + domains: + type: [string, 'null'] + example: 'https://pr.example.com:3000' + docker_compose_domains: + type: [array, 'null'] + items: { properties: { name: { type: string }, domain: { type: [string, 'null'] }, redirect: { type: [string, 'null'], enum: [www, non-www, both] } }, type: object } + force_domain_override: + type: boolean + default: false + type: object + responses: + '200': + description: 'Preview domains updated.' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Domain conflict.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/applications/{uuid}/envs': get: tags: @@ -2974,48 +3106,6 @@ paths: security: - bearerAuth: [] - '/applications/{uuid}/previews/{pull_request_id}': - delete: - tags: - - Applications - summary: 'Delete Preview Deployment' - description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.' - operationId: delete-preview-deployment-by-pull-request-id - parameters: - - - name: uuid - in: path - description: 'UUID of the application.' - required: true - schema: - type: string - - - name: pull_request_id - in: path - description: 'Pull request ID of the preview to delete.' - required: true - schema: - type: integer - responses: - '200': - description: 'Preview deletion queued.' - content: - application/json: - schema: - properties: - message: { type: string } - type: object - '401': - $ref: '#/components/responses/401' - '400': - $ref: '#/components/responses/400' - '404': - $ref: '#/components/responses/404' - '422': - $ref: '#/components/responses/422' - security: - - - bearerAuth: [] '/applications/{uuid}/tags': get: tags: @@ -3720,6 +3810,91 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/imports/uploads': + post: + tags: + - Databases + summary: 'Upload database import' + operationId: upload-database-import + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '201': + description: 'Upload completed' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/imports': + post: + tags: + - Databases + summary: 'Import database backup' + operationId: create-database-import + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseImportRequest' + responses: + '202': + description: 'Import queued' + '409': + description: 'Import already active' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/imports/{activity_id}': + get: + tags: + - Databases + summary: 'Get database import status' + operationId: get-database-import + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: activity_id + in: path + description: 'Import activity ID.' + required: true + schema: + type: integer + responses: + '200': + description: 'Import status' + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseImportStatus' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /databases: get: tags: @@ -3843,6 +4018,12 @@ paths: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' default: 3600 + missing_backup_notification_days: + type: integer + description: 'Alert after this many days without an execution; 0 disables alerts' + minimum: 0 + maximum: 365 + default: 0 type: object responses: '201': @@ -4262,6 +4443,11 @@ paths: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' default: 3600 + missing_backup_notification_days: + type: integer + description: 'Alert after this many days without an execution; 0 disables alerts' + minimum: 0 + maximum: 365 type: object responses: '200': @@ -7490,12 +7676,86 @@ paths: security: - bearerAuth: [] + /settings/email: + get: + tags: + - Settings + summary: 'Get instance email settings' + description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.' + operationId: get-instance-email-settings + responses: + '200': + description: 'Instance email settings.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + security: + - + bearerAuth: [] + patch: + tags: + - Settings + summary: 'Update instance email settings' + description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.' + operationId: update-instance-email-settings + responses: + '200': + description: 'Updated instance email settings.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /security/integration-tokens: + post: + tags: + - 'Secret Managers' + summary: 'Create Secret Manager Token' + description: 'Create and validate a Doppler, Infisical, or Vault integration token.' + operationId: create-secret-manager-integration-token + requestBody: + required: true + content: + application/json: + schema: + required: + - provider + - name + - token + properties: + provider: + type: string + enum: [doppler, infisical, vault] + name: + type: string + token: + type: string + metadata: + type: object + type: object + responses: + '201': + description: 'Integration token created.' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /notifications/email: get: tags: - Notifications summary: 'Get email notification settings' - description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' + description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' operationId: get-current-team-email-notifications responses: '200': @@ -7511,7 +7771,7 @@ paths: tags: - Notifications summary: 'Update email notification settings' - description: 'Update the current team email notification settings.' + description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.' operationId: update-current-team-email-notifications responses: '200': @@ -9872,6 +10132,13 @@ paths: sentinel_push_interval_seconds: { type: integer } sentinel_custom_url: { description: 'Only present with read:sensitive.', type: string } sentinel_updated_at: { type: [string, 'null'] } + traffic_topn: { type: integer } + traffic_sample_threshold: { type: integer } + traffic_retention_1h_days: { type: integer } + traffic_retention_1d_days: { type: integer } + is_geoip_enabled: { type: boolean } + geoip_refresh_days: { type: integer } + geoip_maxmind_license_key: { description: 'Only present with read:sensitive.', type: string } type: object '401': $ref: '#/components/responses/401' @@ -9921,6 +10188,25 @@ paths: minimum: 10 sentinel_custom_url: type: [string, 'null'] + traffic_topn: + type: integer + minimum: 1 + traffic_sample_threshold: + type: integer + minimum: 0 + traffic_retention_1h_days: + type: integer + minimum: 1 + traffic_retention_1d_days: + type: integer + minimum: 1 + is_geoip_enabled: + type: boolean + geoip_refresh_days: + type: integer + minimum: 1 + geoip_maxmind_license_key: + type: [string, 'null'] type: object responses: '200': @@ -10662,6 +10948,8 @@ paths: description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.' type: [integer, 'null'] minimum: 0 + is_force_https_enabled: + type: [boolean, 'null'] type: object responses: '200': @@ -10913,6 +11201,112 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/imports/uploads': + post: + tags: + - 'Service databases' + summary: 'Upload service database import' + operationId: upload-service-database-import + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + responses: + '201': + description: 'Upload completed' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/imports': + post: + tags: + - 'Service databases' + summary: 'Import service database backup' + operationId: create-service-database-import + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseImportRequest' + responses: + '202': + description: 'Import queued' + '409': + description: 'Import already active' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}': + get: + tags: + - 'Service databases' + summary: 'Get service database import status' + operationId: get-service-database-import + parameters: + - + name: uuid + in: path + description: 'Service UUID.' + required: true + schema: + type: string + - + name: database_uuid + in: path + description: 'Service database UUID.' + required: true + schema: + type: string + - + name: activity_id + in: path + description: 'Import activity ID.' + required: true + schema: + type: integer + responses: + '200': + description: 'Import status' + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseImportStatus' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/services/{uuid}/databases': get: tags: @@ -13605,6 +13999,106 @@ paths: bearerAuth: [] components: schemas: + DatabaseImportRequest: + type: object + oneOf: + - + required: + - source + - upload_id + properties: + source: + type: string + enum: + - upload + upload_id: + type: string + format: uuid + dump_all: + type: boolean + default: false + replace_existing: + description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' + type: boolean + default: false + type: object + additionalProperties: false + - + required: + - source + - s3_storage_uuid + - path + properties: + source: + type: string + enum: + - s3 + s3_storage_uuid: + type: string + path: + type: string + dump_all: + type: boolean + default: false + replace_existing: + description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' + type: boolean + default: false + type: object + additionalProperties: false + - + required: + - source + - path + properties: + source: + type: string + enum: + - server + path: + type: string + example: /var/backups/database.sql.gz + dump_all: + type: boolean + default: false + replace_existing: + description: 'Drop matching PostgreSQL objects before restoring a single-database archive.' + type: boolean + default: false + type: object + additionalProperties: false + DatabaseImportStatus: + properties: + id: + type: integer + status: + type: string + enum: + - queued + - in_progress + - finished + - error + - killed + - cancelled + - closed + exit_code: + type: + - integer + - 'null' + output: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + finished_at: + type: + - string + - 'null' + format: date-time + type: object VolumeBackupScheduleRequest: required: - frequency @@ -13663,7 +14157,7 @@ components: minimum: 0 timeout: type: integer - default: 3600 + default: 36000 maximum: 36000 minimum: 60 type: object @@ -14433,6 +14927,8 @@ components: type: integer deployment_queue_limit: type: integer + backup_compression_cpu_percentage: + type: integer dynamic_timeout: type: integer force_disabled: @@ -14455,6 +14951,20 @@ components: type: boolean is_metrics_enabled: type: boolean + is_traffic_analytics_enabled: + type: boolean + traffic_topn: + type: integer + traffic_sample_threshold: + type: integer + traffic_retention_1h_days: + type: integer + traffic_retention_1d_days: + type: integer + is_geoip_enabled: + type: boolean + geoip_refresh_days: + type: integer is_reachable: type: boolean is_sentinel_enabled: @@ -14508,6 +15018,22 @@ components: connection_timeout: type: integer description: 'SSH connection timeout in seconds.' + docker_version: + type: string + nullable: true + description: 'Detected Docker Engine version on the server.' + docker_version_checked_at: + type: string + nullable: true + description: 'When Docker Engine version was last detected.' + compose_version: + type: string + nullable: true + description: 'Detected Docker Compose plugin version on the server.' + compose_version_checked_at: + type: string + nullable: true + description: 'When Docker Compose version was last detected.' type: object Service: description: 'Service model' @@ -14737,6 +15263,9 @@ components: description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.' scheme: bearer tags: + - + name: 'Secret Managers' + description: 'Secret Managers' - name: Applications description: Applications @@ -14767,6 +15296,9 @@ tags: - name: Hetzner description: Hetzner + - + name: Settings + description: Settings - name: Notifications description: Notifications diff --git a/package-lock.json b/package-lock.json index d8cb35e61c..41c4740165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@tailwindcss/typography": "0.5.20", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "cobe": "^2.0.1", "playwright": "^1.58.2", "tw-animate-css": "^1.4.0" }, @@ -697,6 +698,12 @@ "node": ">=6" } }, + "node_modules/cobe": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cobe/-/cobe-2.0.1.tgz", + "integrity": "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", diff --git a/package.json b/package.json index 42f39d29ea..a46a750d46 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@tailwindcss/typography": "0.5.20", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "cobe": "^2.0.1", "playwright": "^1.58.2", "tw-animate-css": "^1.4.0" } diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg new file mode 100644 index 0000000000..9c542584ef --- /dev/null +++ b/public/svgs/oidc.svg @@ -0,0 +1,5 @@ + + OpenID Connect + + + diff --git a/resources/css/app.css b/resources/css/app.css index f9965994ec..a8eef8e2d7 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -98,7 +98,7 @@ .button.button-highlighted:not(:disabled), .button[isHighlighted]:not(:disabled) { - --button-depth-color: color-mix(in oklab, var(--color-coollabs) 52%, black); + --button-depth-color: var(--color-coollabs-300); } .dark .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled) { @@ -168,6 +168,50 @@ select, transition-duration: 120ms; } +/* + Traffic-analytics chart tokens (light defaults; dark overrides below). + One source of truth shared by ApexCharts donuts, the geo choropleth SVG, + and proportional bars β€” JS reads them at runtime via getComputedStyle. + Palette validated with the dataviz skill; contrast ratios are recorded in + the PR description. See resources/views/livewire/traffic/_geo.blade.php. +*/ +:root { + /* Categorical HTTP status palette (labelled 2xx/3xx/4xx/5xx in every legend). */ + --chart-status-2xx: #15803d; + --chart-status-3xx: #2563eb; + --chart-status-4xx: #d97706; + --chart-status-5xx: #dc2626; + + /* KPI sparkline accent for Bandwidth (violet β€” distinct from the status hues). */ + --chart-spark-bandwidth: #7c3aed; + + /* Sequential 5-step geo ramp (low -> high traffic) + neutral empty. */ + --chart-geo-1: #3b82f6; + --chart-geo-2: #2563eb; + --chart-geo-3: #1d4ed8; + --chart-geo-4: #1e40af; + --chart-geo-5: #172554; + --chart-geo-empty: #e5e7eb; + --chart-geo-stroke: #ffffff; +} + +.dark { + --chart-status-2xx: #22c55e; + --chart-status-3xx: #3b82f6; + --chart-status-4xx: #f59e0b; + --chart-status-5xx: #ef4444; + + --chart-spark-bandwidth: #a78bfa; + + --chart-geo-1: #2563eb; + --chart-geo-2: #3b82f6; + --chart-geo-3: #60a5fa; + --chart-geo-4: #93c5fd; + --chart-geo-5: #bfdbfe; + --chart-geo-empty: #262626; + --chart-geo-stroke: #101010; +} + /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -2262,7 +2306,7 @@ html[data-theme="custom"] textarea:disabled { outline: none; position: relative; z-index: 1; - box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-accent) 40%, transparent); + box-shadow: 0 0 0 1px var(--color-accent); } .application-heading-actions .split-action-main, @@ -3925,6 +3969,232 @@ html[data-theme="custom"] .logs-viewer-timestamp { color: var(--color-fg-dim); } +.runtime-log-panel { + --runtime-log-line: rgba(0, 0, 0, 0.08); + --runtime-log-muted: #66666f; + --runtime-log-hover: rgba(0, 0, 0, 0.04); + --runtime-log-detail: rgba(0, 0, 0, 0.03); + --runtime-log-columns: 12.75rem 5.5rem minmax(0, 1fr); +} + +.dark .runtime-log-panel { + --runtime-log-line: var(--glass-line, rgba(255, 255, 255, 0.065)); + --runtime-log-muted: #a09da5; + --runtime-log-hover: rgba(255, 255, 255, 0.035); + --runtime-log-detail: rgba(0, 0, 0, 0.24); +} + +.runtime-log-viewport.logs-viewer-viewport { + container: runtime-log-explorer / inline-size; + padding: 0; +} + +.runtime-log-viewport.logs-viewer-viewport::after { + display: none; +} + +.runtime-log-columns, +.runtime-log-viewport [data-log-line]:not(.hidden) { + display: grid; + grid-template-columns: var(--runtime-log-columns); + gap: 0.625rem; + box-sizing: border-box; + width: 100%; + min-height: 2.75rem; + align-items: center; + padding: 0.6875rem 1.875rem 0.6875rem 1rem; +} + +.runtime-log-columns { + position: sticky; + top: 0; + z-index: 10; + border-bottom: 1px solid var(--runtime-log-line); + background: #f0eff2; + color: var(--runtime-log-muted); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; +} + +.dark .runtime-log-columns { + background: var(--coollabs-elevated); +} + +html[data-theme="custom"] .runtime-log-columns { + background: var(--color-log-toolbar); +} + +.runtime-log-viewport [data-log-line] { + position: relative; + border-bottom: 1px solid var(--runtime-log-line); + border-radius: 0; + cursor: pointer; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + line-height: 1.6; +} + +.runtime-log-viewport [data-log-line]:hover, +.runtime-log-viewport [data-log-line][aria-expanded="true"] { + background: var(--runtime-log-hover); +} + +.runtime-log-viewport [data-log-line]:focus-visible { + outline: 2px solid var(--color-accent, #b93642); + outline-offset: -2px; +} + +.runtime-log-viewport [data-log-line]::before { + content: "[" attr(data-log-level) "]"; + grid-column: 2; + grid-row: 1; + color: var(--runtime-log-muted); + text-transform: uppercase; +} + +.runtime-log-viewport [data-log-line]::after { + content: ""; + position: absolute; + top: 1rem; + right: 0.75rem; + width: 0.375rem; + height: 0.375rem; + border-right: 1.5px solid var(--runtime-log-muted); + border-bottom: 1.5px solid var(--runtime-log-muted); + transform: rotate(45deg); +} + +.runtime-log-viewport [data-log-line][aria-expanded="true"]::after { + top: 1.1875rem; + transform: rotate(225deg); +} + +.runtime-log-viewport .log-error::before { color: #e55e73; } +.runtime-log-viewport .log-warning::before { color: #d79945; } +.runtime-log-viewport .log-debug::before { color: #929099; } +.runtime-log-viewport .log-info::before { color: #8891f0; } + +.runtime-log-viewport .logs-viewer-timestamp { + grid-column: 1; + grid-row: 1; + color: var(--runtime-log-muted); + font-size: 0.8125rem; + line-height: 1.6; + white-space: nowrap; +} + +.runtime-log-viewport [data-line-text] { + grid-column: 3; + grid-row: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-log-detail { + margin: 0 0 0.25rem; + padding: 1.25rem; + border-bottom: 1px solid var(--runtime-log-line); + background: var(--runtime-log-detail); + color: inherit; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + line-height: 1.8; +} + +.dark .runtime-log-detail { + color: #adbdc9; +} + +.runtime-log-viewport [data-log-line].hidden + .runtime-log-detail { + display: none !important; +} + +.runtime-log-without-time { + --runtime-log-columns: 5.5rem minmax(0, 1fr); +} + +.runtime-log-without-time [data-log-line]::before { + grid-column: 1; +} + +.runtime-log-without-time [data-line-text] { + grid-column: 2; +} + +@container runtime-log-explorer (max-width: 650px) { + .runtime-log-columns, + .runtime-log-viewport [data-log-line]:not(.hidden) { + grid-template-columns: minmax(0, 1fr) 5.5rem; + gap: 0.1875rem 0.5rem; + } + + .runtime-log-columns > :last-child, + .runtime-log-viewport [data-line-text] { + grid-column: 1 / -1; + grid-row: 2; + } + + .runtime-log-without-time [data-line-text] { + grid-column: 1 / -1; + } +} + +.runtime-log-empty { + display: flex; + min-height: 18rem; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + color: var(--runtime-log-muted); + text-align: left; +} + +.runtime-log-loading { + min-height: 18rem; + align-items: center; + justify-content: center; + gap: 0.625rem; + padding: 2rem; + color: var(--runtime-log-muted); + font-size: 0.8125rem; + font-weight: 500; +} + +.runtime-log-empty-icon { + display: inline-flex; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + border: 1px solid var(--runtime-log-line); + border-radius: 0.5rem; + background: var(--runtime-log-detail); +} + +.runtime-log-empty p { + color: inherit; + font-size: 0.8125rem; + font-weight: 500; +} + +.runtime-log-empty div > span { + display: block; + margin-top: 0.125rem; + font-size: 0.75rem; + line-height: 1.25rem; +} + +.env-table-detail { + padding: 0.25rem 1rem 1.25rem; +} + /* Small pill badges for table cells */ .table-badge { display: inline-flex; diff --git a/resources/js/app.js b/resources/js/app.js index 10d3c2d01c..2e0056011b 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,6 @@ +import { initializeCopyButtonComponent } from './copy-button.js'; import { initializeTerminalComponent } from './terminal.js'; +import './traffic-globe.js'; import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js'; document.addEventListener('livewire:init', () => { @@ -17,6 +19,7 @@ document.addEventListener('livewire:navigated', () => { // Keeping this registration independent from the current route also makes it // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); +document.addEventListener('alpine:init', initializeCopyButtonComponent); /** * Smooth-scroll a settings section into view, then flash its border for 500ms diff --git a/resources/js/copy-button.js b/resources/js/copy-button.js new file mode 100644 index 0000000000..0ce8d5d67d --- /dev/null +++ b/resources/js/copy-button.js @@ -0,0 +1,35 @@ +// Alpine data provider for the component (x-data="copyButton"). +export function initializeCopyButtonComponent() { + window.Alpine.data('copyButton', () => ({ + copied: false, + async copy(value) { + if (value === null || value === undefined) { + window.toast('Value is not available.', { type: 'warning' }); + return; + } + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + // Deprecated, but the only copy path on plain http (non-secure contexts). + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!ok) { + throw new Error('Copy command was rejected.'); + } + } + this.copied = true; + setTimeout(() => (this.copied = false), 1200); + } catch (e) { + window.toast('Could not copy to clipboard.', { type: 'warning' }); + } + }, + })); +} diff --git a/resources/js/traffic-globe.js b/resources/js/traffic-globe.js new file mode 100644 index 0000000000..49f52ac9ac --- /dev/null +++ b/resources/js/traffic-globe.js @@ -0,0 +1,239 @@ +import createGlobe from 'cobe'; + +// ISO-3166 alpha-2 -> [lat, lng] centroids (Google public-data canonical set). +// Used to place request-volume markers on the interactive globe. Kept inline so +// the globe has zero runtime fetch dependency. +const CENTROIDS = {"AD":[42.546245,1.601554],"AE":[23.424076,53.847818],"AF":[33.93911,67.709953],"AG":[17.060816,-61.796428],"AI":[18.220554,-63.068615],"AL":[41.153332,20.168331],"AM":[40.069099,45.038189],"AN":[12.226079,-69.060087],"AO":[-11.202692,17.873887],"AQ":[-75.250973,-0.071389],"AR":[-38.416097,-63.616672],"AS":[-14.270972,-170.132217],"AT":[47.516231,14.550072],"AU":[-25.274398,133.775136],"AW":[12.52111,-69.968338],"AZ":[40.143105,47.576927],"BA":[43.915886,17.679076],"BB":[13.193887,-59.543198],"BD":[23.684994,90.356331],"BE":[50.503887,4.469936],"BF":[12.238333,-1.561593],"BG":[42.733883,25.48583],"BH":[25.930414,50.637772],"BI":[-3.373056,29.918886],"BJ":[9.30769,2.315834],"BM":[32.321384,-64.75737],"BN":[4.535277,114.727669],"BO":[-16.290154,-63.588653],"BR":[-14.235004,-51.92528],"BS":[25.03428,-77.39628],"BT":[27.514162,90.433601],"BV":[-54.423199,3.413194],"BW":[-22.328474,24.684866],"BY":[53.709807,27.953389],"BZ":[17.189877,-88.49765],"CA":[56.130366,-106.346771],"CC":[-12.164165,96.870956],"CD":[-4.038333,21.758664],"CF":[6.611111,20.939444],"CG":[-0.228021,15.827659],"CH":[46.818188,8.227512],"CI":[7.539989,-5.54708],"CK":[-21.236736,-159.777671],"CL":[-35.675147,-71.542969],"CM":[7.369722,12.354722],"CN":[35.86166,104.195397],"CO":[4.570868,-74.297333],"CR":[9.748917,-83.753428],"CU":[21.521757,-77.781167],"CV":[16.002082,-24.013197],"CX":[-10.447525,105.690449],"CY":[35.126413,33.429859],"CZ":[49.817492,15.472962],"DE":[51.165691,10.451526],"DJ":[11.825138,42.590275],"DK":[56.26392,9.501785],"DM":[15.414999,-61.370976],"DO":[18.735693,-70.162651],"DZ":[28.033886,1.659626],"EC":[-1.831239,-78.183406],"EE":[58.595272,25.013607],"EG":[26.820553,30.802498],"EH":[24.215527,-12.885834],"ER":[15.179384,39.782334],"ES":[40.463667,-3.74922],"ET":[9.145,40.489673],"FI":[61.92411,25.748151],"FJ":[-16.578193,179.414413],"FK":[-51.796253,-59.523613],"FM":[7.425554,150.550812],"FO":[61.892635,-6.911806],"FR":[46.227638,2.213749],"GA":[-0.803689,11.609444],"GB":[55.378051,-3.435973],"GD":[12.262776,-61.604171],"GE":[42.315407,43.356892],"GF":[3.933889,-53.125782],"GG":[49.465691,-2.585278],"GH":[7.946527,-1.023194],"GI":[36.137741,-5.345374],"GL":[71.706936,-42.604303],"GM":[13.443182,-15.310139],"GN":[9.945587,-9.696645],"GP":[16.995971,-62.067641],"GQ":[1.650801,10.267895],"GR":[39.074208,21.824312],"GS":[-54.429579,-36.587909],"GT":[15.783471,-90.230759],"GU":[13.444304,144.793731],"GW":[11.803749,-15.180413],"GY":[4.860416,-58.93018],"GZ":[31.354676,34.308825],"HK":[22.396428,114.109497],"HM":[-53.08181,73.504158],"HN":[15.199999,-86.241905],"HR":[45.1,15.2],"HT":[18.971187,-72.285215],"HU":[47.162494,19.503304],"ID":[-0.789275,113.921327],"IE":[53.41291,-8.24389],"IL":[31.046051,34.851612],"IM":[54.236107,-4.548056],"IN":[20.593684,78.96288],"IO":[-6.343194,71.876519],"IQ":[33.223191,43.679291],"IR":[32.427908,53.688046],"IS":[64.963051,-19.020835],"IT":[41.87194,12.56738],"JE":[49.214439,-2.13125],"JM":[18.109581,-77.297508],"JO":[30.585164,36.238414],"JP":[36.204824,138.252924],"KE":[-0.023559,37.906193],"KG":[41.20438,74.766098],"KH":[12.565679,104.990963],"KI":[-3.370417,-168.734039],"KM":[-11.875001,43.872219],"KN":[17.357822,-62.782998],"KP":[40.339852,127.510093],"KR":[35.907757,127.766922],"KW":[29.31166,47.481766],"KY":[19.513469,-80.566956],"KZ":[48.019573,66.923684],"LA":[19.85627,102.495496],"LB":[33.854721,35.862285],"LC":[13.909444,-60.978893],"LI":[47.166,9.555373],"LK":[7.873054,80.771797],"LR":[6.428055,-9.429499],"LS":[-29.609988,28.233608],"LT":[55.169438,23.881275],"LU":[49.815273,6.129583],"LV":[56.879635,24.603189],"LY":[26.3351,17.228331],"MA":[31.791702,-7.09262],"MC":[43.750298,7.412841],"MD":[47.411631,28.369885],"ME":[42.708678,19.37439],"MG":[-18.766947,46.869107],"MH":[7.131474,171.184478],"MK":[41.608635,21.745275],"ML":[17.570692,-3.996166],"MM":[21.913965,95.956223],"MN":[46.862496,103.846656],"MO":[22.198745,113.543873],"MP":[17.33083,145.38469],"MQ":[14.641528,-61.024174],"MR":[21.00789,-10.940835],"MS":[16.742498,-62.187366],"MT":[35.937496,14.375416],"MU":[-20.348404,57.552152],"MV":[3.202778,73.22068],"MW":[-13.254308,34.301525],"MX":[23.634501,-102.552784],"MY":[4.210484,101.975766],"MZ":[-18.665695,35.529562],"NA":[-22.95764,18.49041],"NC":[-20.904305,165.618042],"NE":[17.607789,8.081666],"NF":[-29.040835,167.954712],"NG":[9.081999,8.675277],"NI":[12.865416,-85.207229],"NL":[52.132633,5.291266],"NO":[60.472024,8.468946],"NP":[28.394857,84.124008],"NR":[-0.522778,166.931503],"NU":[-19.054445,-169.867233],"NZ":[-40.900557,174.885971],"OM":[21.512583,55.923255],"PA":[8.537981,-80.782127],"PE":[-9.189967,-75.015152],"PF":[-17.679742,-149.406843],"PG":[-6.314993,143.95555],"PH":[12.879721,121.774017],"PK":[30.375321,69.345116],"PL":[51.919438,19.145136],"PM":[46.941936,-56.27111],"PN":[-24.703615,-127.439308],"PR":[18.220833,-66.590149],"PS":[31.952162,35.233154],"PT":[39.399872,-8.224454],"PW":[7.51498,134.58252],"PY":[-23.442503,-58.443832],"QA":[25.354826,51.183884],"RE":[-21.115141,55.536384],"RO":[45.943161,24.96676],"RS":[44.016521,21.005859],"RU":[61.52401,105.318756],"RW":[-1.940278,29.873888],"SA":[23.885942,45.079162],"SB":[-9.64571,160.156194],"SC":[-4.679574,55.491977],"SD":[12.862807,30.217636],"SE":[60.128161,18.643501],"SG":[1.352083,103.819836],"SH":[-24.143474,-10.030696],"SI":[46.151241,14.995463],"SJ":[77.553604,23.670272],"SK":[48.669026,19.699024],"SL":[8.460555,-11.779889],"SM":[43.94236,12.457777],"SN":[14.497401,-14.452362],"SO":[5.152149,46.199616],"SR":[3.919305,-56.027783],"ST":[0.18636,6.613081],"SV":[13.794185,-88.89653],"SY":[34.802075,38.996815],"SZ":[-26.522503,31.465866],"TC":[21.694025,-71.797928],"TD":[15.454166,18.732207],"TF":[-49.280366,69.348557],"TG":[8.619543,0.824782],"TH":[15.870032,100.992541],"TJ":[38.861034,71.276093],"TK":[-8.967363,-171.855881],"TL":[-8.874217,125.727539],"TM":[38.969719,59.556278],"TN":[33.886917,9.537499],"TO":[-21.178986,-175.198242],"TR":[38.963745,35.243322],"TT":[10.691803,-61.222503],"TV":[-7.109535,177.64933],"TW":[23.69781,120.960515],"TZ":[-6.369028,34.888822],"UA":[48.379433,31.16558],"UG":[1.373333,32.290275],"US":[37.09024,-95.712891],"UY":[-32.522779,-55.765835],"UZ":[41.377491,64.585262],"VA":[41.902916,12.453389],"VC":[12.984305,-61.287228],"VE":[6.42375,-66.58973],"VG":[18.420695,-64.639968],"VI":[18.335765,-64.896335],"VN":[14.058324,108.277199],"VU":[-15.376706,166.959158],"WF":[-13.768752,-177.156097],"WS":[-13.759029,-172.104629],"XK":[42.602636,20.902977],"YE":[15.552727,48.516388],"YT":[-12.8275,45.166244],"ZA":[-30.559482,22.937506],"ZM":[-13.133897,27.849332],"ZW":[-19.015438,29.154857]}; + +// Palettes for the dotted globe, tuned to the analytics chart tokens (blue markers). +const THEMES = { + dark: { + dark: 1, + baseColor: [0.45, 0.5, 0.62], + markerColor: [0.36, 0.6, 1], + glowColor: [0.12, 0.16, 0.26], + mapBrightness: 11, + }, + light: { + dark: 0, + baseColor: [0.82, 0.85, 0.9], + markerColor: [0.13, 0.36, 0.92], + glowColor: [1, 1, 1], + mapBrightness: 9, + }, +}; + +/** + * Turn country-breakdown rows into cobe markers. Marker size scales with the + * square root of request volume so a single dominant country doesn't dwarf the rest. + * + * @param {Array<{code: string, requests: number}>} data + * @returns {Array<{location: [number, number], size: number}>} + */ +function buildMarkers(data) { + const rows = (data || []) + .map((r) => ({ code: String(r.code || '').toUpperCase(), requests: Number(r.requests || 0) })) + .filter((r) => r.requests > 0 && CENTROIDS[r.code]); + + if (rows.length === 0) { + return []; + } + + const max = Math.max(...rows.map((r) => r.requests)); + + return rows.map((r) => ({ + location: CENTROIDS[r.code], + size: Math.max(0.03, Math.min(0.11, Math.sqrt(r.requests / max) * 0.11)), + })); +} + +const TWO_PI = Math.PI * 2; + +// cobe orientation for a lat/lng so the point faces the viewer (cobe's own +// focus example formula). Returns [phi, theta]. +function locationToAngles(lat, lng) { + return [Math.PI - ((lng * Math.PI) / 180 - Math.PI / 2), (lat * Math.PI) / 180]; +} + +// Shortest-path angular interpolation, so easing across the 0/2Ο€ seam never +// spins the long way around. +function lerpAngle(current, target, t) { + let delta = ((target - current + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI; + + return current + delta * t; +} + +/** + * Mount an interactive, drag-to-rotate dotted globe onto a canvas. Returns a + * controller with `update(data, dark)`, `focus(code)`, `resume()` and + * `destroy()`. The globe auto-rotates, pauses while grabbed or while a country + * is hover-focused, and eases smoothly toward whatever it's pointed at. + * + * cobe v2 has no internal render loop or `onRender` callback: createGlobe draws + * a single frame and returns `{ update, destroy }`. We drive our own rAF loop, + * calling `globe.update({...})` each frame for rotation and to swap markers/theme. + * + * @param {HTMLCanvasElement} canvas + * @param {Array<{code: string, requests: number}>} data + * @param {boolean} dark + */ +function mountTrafficGlobe(canvas, data, dark) { + let globe = null; + let width = 0; + let destroyed = false; + let rafId = 0; + let markers = buildMarkers(data); + + // Ambient spin is decorative; honor reduced-motion by not auto-rotating + // (drag + hover-focus still work β€” those are user-initiated). + const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + // Rotation is a single moving target the loop eases toward each frame. + let targetPhi = 0; + let currentPhi = 0; + let targetTheta = 0.2; + let currentTheta = 0.2; + let autoRotate = !prefersReduced; + + let dragging = null; // clientX at pointerdown + let dragStartPhi = 0; + + const onPointerDown = (e) => { + dragging = e.clientX; + dragStartPhi = targetPhi; + autoRotate = false; + canvas.style.cursor = 'grabbing'; + }; + const onPointerUp = () => { + if (dragging === null) { + return; + } + dragging = null; + autoRotate = ! prefersReduced; + canvas.style.cursor = 'grab'; + }; + const onPointerMove = (e) => { + if (dragging !== null) { + targetPhi = dragStartPhi + (e.clientX - dragging) / 150; + } + }; + + canvas.addEventListener('pointerdown', onPointerDown); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointermove', onPointerMove); + canvas.style.cursor = 'grab'; + + const create = (isDark) => { + const theme = isDark ? THEMES.dark : THEMES.light; + + globe = createGlobe(canvas, { + devicePixelRatio: 2, + width: width, + height: width, + phi: currentPhi, + theta: currentTheta, + diffuse: 1.2, + mapSamples: 16000, + mapBrightness: theme.mapBrightness, + dark: theme.dark, + baseColor: theme.baseColor, + markerColor: theme.markerColor, + glowColor: theme.glowColor, + opacity: 0.92, + markers: markers, + }); + }; + + // Self-driven animation loop (cobe v2 draws only when we call update()). + const tick = () => { + if (destroyed) { + return; + } + if (globe && width > 0) { + if (autoRotate && dragging === null) { + targetPhi += 0.0025; + } + currentPhi = lerpAngle(currentPhi, targetPhi, 0.12); + currentTheta += (targetTheta - currentTheta) * 0.12; + globe.update({ phi: currentPhi, theta: currentTheta, width: width, height: width, markers }); + } + rafId = requestAnimationFrame(tick); + }; + + // cobe needs a non-zero canvas width at creation; inside a freshly-rendered + // or momentarily-hidden container offsetWidth can be 0, which yields a blank + // globe (only the grab cursor shows). Create once a real width is known. + const ensure = () => { + const next = canvas.offsetWidth; + if (destroyed || next === 0 || globe) { + return; + } + width = next; + create(dark); + rafId = requestAnimationFrame(tick); + }; + + const resizeObserver = new ResizeObserver(() => { + if (globe) { + width = canvas.offsetWidth || width; + } else { + ensure(); + } + }); + resizeObserver.observe(canvas); + requestAnimationFrame(ensure); + + return { + update(newData, isDark) { + if (destroyed) { + return; + } + data = newData; + dark = isDark; + markers = buildMarkers(newData); + if (globe) { + const theme = isDark ? THEMES.dark : THEMES.light; + globe.update({ + markers, + dark: theme.dark, + mapBrightness: theme.mapBrightness, + baseColor: theme.baseColor, + markerColor: theme.markerColor, + glowColor: theme.glowColor, + }); + } + }, + focus(code) { + const c = CENTROIDS[String(code || '').toUpperCase()]; + if (!c) { + return; + } + const [phi, theta] = locationToAngles(c[0], c[1]); + targetPhi = phi; + targetTheta = theta; + autoRotate = false; + }, + resume() { + if (dragging === null) { + autoRotate = ! prefersReduced; + } + }, + destroy() { + destroyed = true; + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + resizeObserver.disconnect(); + canvas.removeEventListener('pointerdown', onPointerDown); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointermove', onPointerMove); + if (globe) { + globe.destroy(); + globe = null; + } + }, + }; +} + +window.mountTrafficGlobe = mountTrafficGlobe; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 829a26cad3..12eb57867c 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -80,11 +80,15 @@ @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
-
+
@foreach ($enabled_oauth_providers as $provider_setting) - {{ __("auth.login.$provider_setting->provider") }} + @if ($provider_setting->provider !== 'oidc') + + @endif + {{ $provider_setting->loginLabel() }} @endforeach
diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 9a1405affb..604513b269 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -115,6 +115,11 @@ 'route' => 'project.application.metrics', 'active' => $currentRoute === 'project.application.metrics', ], + [ + 'label' => 'Analytics', + 'route' => 'project.application.analytics', + 'active' => $currentRoute === 'project.application.analytics', + ], [ 'label' => 'Tags', 'route' => 'project.application.tags', @@ -154,6 +159,7 @@ 'Resource Limits' => 'cpu', 'Resource Operations' => 'server-update', 'Metrics' => 'graph', + 'Analytics' => 'analytics', 'Tags' => 'tags', 'Danger Zone' => 'shield-alert', ]; @@ -161,7 +167,7 @@ // Discord-style groups for the settings sidebar $menuGroups = [ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'], - 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics'], + 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics', 'Analytics'], 'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'], 'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'], diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php index dfdceef20b..3333a62bfa 100644 --- a/resources/views/components/copy-button.blade.php +++ b/resources/views/components/copy-button.blade.php @@ -1,22 +1,20 @@ @props([ - 'value', + 'value' => null, + 'resolve' => null, 'label' => 'Copy to clipboard', ]) - diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php deleted file mode 100644 index e299610eb2..0000000000 --- a/resources/views/components/forms/copy-button.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -@props(['text', 'label' => null]) - -
- @if ($label) - - @endif -
- - -
-
diff --git a/resources/views/components/forms/copy-input.blade.php b/resources/views/components/forms/copy-input.blade.php new file mode 100644 index 0000000000..d31fac0bca --- /dev/null +++ b/resources/views/components/forms/copy-input.blade.php @@ -0,0 +1,15 @@ +@props(['text', 'label' => null]) + +
+ @if ($label) + + @endif +
+ + +
+
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index 378a3947e3..41a29fbbdb 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -20,13 +20,32 @@ cursorPosition: 0, currentScope: null, availableVars: @js($availableVars), + hasVaultSource: @js($hasVaultSource), + vaultKeysLoading: false, get availableScopes() { // Only include scopes that have at least one variable const allScopes = ['team', 'project', 'environment', 'server']; - return allScopes.filter(scope => { + const scopes = allScopes.filter(scope => { const vars = this.availableVars[scope]; return vars && vars.length > 0; }); + // The vault scope is offered whenever a secret manager source is + // configured; its keys are fetched lazily on first use. + if (this.hasVaultSource) { + scopes.push('vault'); + } + return scopes; + }, + loadVaultKeys() { + if (this.vaultKeysLoading) return; + this.vaultKeysLoading = true; + this.$wire.fetchSecretManagerKeys().then(keys => { + this.availableVars['vault'] = keys || []; + this.vaultKeysLoading = false; + this.handleInput(); + }).catch(() => { + this.vaultKeysLoading = false; + }); }, scopeUrls: @js($scopeUrls), @@ -84,6 +103,15 @@ } this.currentScope = scope; + + // Vault keys are fetched from the secret manager on first use. + if (scope === 'vault' && this.availableVars['vault'] === undefined) { + this.loadVaultKeys(); + this.suggestions = []; + this.showDropdown = true; + return; + } + const scopeVars = this.availableVars[scope] || []; const filtered = scopeVars.filter(v => v.toLowerCase().includes((partial || '').toLowerCase()) @@ -214,6 +242,7 @@ wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif wire:loading.attr="disabled" + wire:target.except="fetchSecretManagerKeys" @disabled($disabled) @if ($type !== 'password') type="{{ $type }}" @@ -236,7 +265,14 @@
-
@endif + @include('livewire.project.shared.dns-provider-management')
diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index ccfa948f4b..e2af1e96aa 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -43,110 +43,69 @@
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))) @can('deploy', $application) -
- - - -
+ @endcan @endif @can('deploy', $application) -
- - - -
+ @endcan @endif
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php index 8ab1442ba5..6997b766b8 100644 --- a/resources/views/livewire/project/application/internal-access.blade.php +++ b/resources/views/livewire/project/application/internal-access.blade.php @@ -15,7 +15,7 @@

Internal access

@if ($currentInternalHostname) - + @else
@@ -25,9 +25,9 @@ readonly aria-live="polite">
@endif - - - + + +

diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index ce973200fb..018beefebd 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -159,10 +159,12 @@ + ]" :checkboxes="[['id' => 'deleteManagedDns', 'label' => 'Also delete the DNS record created by Coolify, if present.']]" + :confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Remove domain"> - -

-
+ @endcan @endif @@ -145,25 +105,24 @@
@if ($database->destination->server->isFunctional()) @can('manage', $database) -
- @if (! $databaseStatus->startsWith('exited')) - - - @else - - Start - - @endif -
+ + @if (! $databaseStatus->startsWith('exited')) + + + Restart + + + @else + + + Start + + @endif + @endcan @else diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php index 6bb5892dca..9d70a41780 100644 --- a/resources/views/livewire/project/database/import-form.blade.php +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -48,8 +48,8 @@ @endscript
- - Restoring a backup is destructive. Review the source and import command before continuing. + + Review the source and import command before continuing. Existing objects can cause the import to fail unless replacement is enabled. @else - @endif @@ -95,6 +95,13 @@ ['value' => false, 'label' => 'Backup contains one database'], ]" />
+ @if (in_array($resourceDbType, ['standalone-postgresql', 'postgresql'], true) && ! $dumpAll) +
+ +
+ @endif
@@ -185,7 +192,7 @@
  • Copy backup file to database container
  • Execute restore command
  • -

    All existing data will be replaced.

    +

    Existing objects can cause the import to fail unless replacement is enabled.

    @@ -245,7 +252,7 @@
  • Copy file into database container
  • Execute restore command
  • -

    All existing data will be replaced.

    +

    Existing objects can cause the import to fail unless replacement is enabled.

    diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index e88c5bc2f2..a129884644 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -180,6 +180,7 @@ @elseif ($currentRoute === 'project.service.environment-variables') + @elseif ($currentRoute === 'project.service.storages')
    @endif + @include('livewire.project.shared.dns-provider-management')
    diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index 22ce7dab3e..8c6531f3bc 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -72,8 +72,7 @@ {{ $service->name }}
    - +
    @@ -87,92 +86,45 @@
    @if ($service->isDeployable) @can('deploy', $service) -
    - - - -
    + @endcan @else @can('deploy', $service) @@ -225,79 +176,57 @@
    @can('deploy', $service) -
    - - -
    + + @else + + + + Deploy + + + + @endif + @endcan @else @can('deploy', $service) diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 97127904e4..7038756440 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -194,10 +194,12 @@