From 3afc1d46dea06ebe1a7e931ff12d000b1f269a76 Mon Sep 17 00:00:00 2001 From: Mattia Trapani Date: Thu, 19 Mar 2026 16:08:39 +0100 Subject: [PATCH 01/68] Fix environment variable defaults in rallly.yaml - SERVICE_URL already includes protocol - SMTP_SECURE and SMTP_TLS_ENABLED need a default value --- templates/compose/rallly.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/rallly.yaml b/templates/compose/rallly.yaml index 0dfc84c56..477dd6a5d 100644 --- a/templates/compose/rallly.yaml +++ b/templates/compose/rallly.yaml @@ -32,15 +32,15 @@ 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} + - SMTP_TLS_ENABLED=${SMTP_TLS_ENABLED:-false} healthcheck: test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/3000' || exit 1"] interval: 5s From 485a57fb1b2319f3b02991bb13f28cb40f8bbe01 Mon Sep 17 00:00:00 2001 From: Mattia Trapani Date: Thu, 19 Mar 2026 16:11:50 +0100 Subject: [PATCH 02/68] SMTP_TLS_ENABLED deprecated --- templates/compose/rallly.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/rallly.yaml b/templates/compose/rallly.yaml index 477dd6a5d..45cb51aff 100644 --- a/templates/compose/rallly.yaml +++ b/templates/compose/rallly.yaml @@ -40,7 +40,6 @@ services: - SMTP_SECURE=${SMTP_SECURE:-false} - SMTP_USER=${SMTP_USER} - SMTP_PWD=${SMTP_PWD} - - SMTP_TLS_ENABLED=${SMTP_TLS_ENABLED:-false} healthcheck: test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/3000' || exit 1"] interval: 5s From 903837c96a87ca2f49d9b0440bdba148621a5fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henrique=20Ara=C3=BAjo?= Date: Wed, 1 Apr 2026 13:19:47 +0000 Subject: [PATCH 03/68] fix: add missing database alteration step for latest image version When upgrading to the latest image version, the database alteration command was missing, causing the application to fail due to schema mismatch. This change ensures the database is properly migrated to the latest version during startup. --- templates/compose/logto.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 519a186e841d0d5c3f56c38af8aeb7cfc7d63cdd Mon Sep 17 00:00:00 2001 From: Tristan Rhodes Date: Thu, 9 Apr 2026 09:38:56 -0600 Subject: [PATCH 04/68] fix: normalize oauth emails before matching users --- app/Http/Controllers/OauthController.php | 9 ++- tests/Feature/OauthControllerTest.php | 79 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/OauthControllerTest.php 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/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' => [' '], +]); From 0daf450efb8bb84ae4ec1995d4909a3c91e7164d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:04:15 +0000 Subject: [PATCH 05/68] build(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0 Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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": [ { From c07053d28b5e8ddd33f7212b628808f51ba996f1 Mon Sep 17 00:00:00 2001 From: miqonee Date: Thu, 16 Apr 2026 18:44:23 +0700 Subject: [PATCH 06/68] fix(templates): restore Jitsi Meet service template (#4813) --- svgs/jitsi.svg | 650 +++++++++++++++++++++++++++++++++++ templates/compose/jitsi.yaml | 144 ++++---- 2 files changed, 731 insertions(+), 63 deletions(-) create mode 100644 svgs/jitsi.svg 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/jitsi.yaml b/templates/compose/jitsi.yaml index 60903a4b6..db119e4a0 100644 --- a/templates/compose/jitsi.yaml +++ b/templates/compose/jitsi.yaml @@ -1,119 +1,136 @@ -# ignore: true -# documentation: https://jitsi.github.io/handbook/docs/intro -# category: productivity -# slogan: World's easiest way to add meetings to your apps +# 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 # 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 @@ -122,6 +139,7 @@ networks: volumes: jitsi-web: - jitsi-xmpp: + jitsi-prosody: jitsi-jicofo: jitsi-jvb: + \ No newline at end of file From e18ac5a7e810f0e41b8b1483fe6b78e776da663a Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:18:19 +0530 Subject: [PATCH 07/68] fix(service): twenty fails to deploy due to dependency unhealthy --- templates/compose/twenty.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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: From bceb5f28dce3e7060458ef97490e16b7796ac743 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:29:11 +0200 Subject: [PATCH 08/68] feat(applications): add DELETE endpoint for preview deployments by PR id Add `DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}` to cancel active deployments, stop containers, and delete the preview record via `CleanupPreviewDeployment`. Includes OpenAPI annotations, input validation, and full feature test coverage. --- .../Api/ApplicationsController.php | 73 +++++++++- openapi.json | 64 +++++++++ openapi.yaml | 42 ++++++ routes/api.php | 4 +- tests/Feature/ApplicationPreviewApiTest.php | 132 ++++++++++++++++++ 5 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/ApplicationPreviewApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3d92300f1..cc7c0dbbe 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)) { @@ -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/openapi.json b/openapi.json index e4e03c99d..d83b30d80 100644 --- a/openapi.json +++ b/openapi.json @@ -3788,6 +3788,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 f2761de59..aab408098 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/routes/api.php b/routes/api.php index 0d3edcced..6d48fbe74 100644 --- a/routes/api.php +++ b/routes/api.php @@ -129,6 +129,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 +220,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/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(); + }); +}); From 340cd70ae5e8651fa35f832b5eaecc78f4abb016 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:29:47 +0200 Subject: [PATCH 09/68] chore(ui): add a deprecated notice component --- resources/views/components/deprecated-badge.blade.php | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 resources/views/components/deprecated-badge.blade.php 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 + + From 15cb9446ff7c2ec75814f6ed7fbebe3e57262b20 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:28:54 +0200 Subject: [PATCH 10/68] chore(swarm): mark docker swarm as deprecated --- config/deprecations.php | 5 +++++ resources/views/components/server/sidebar.blade.php | 2 +- resources/views/livewire/destination/index.blade.php | 5 ++++- resources/views/livewire/destination/show.blade.php | 4 +++- .../livewire/project/application/configuration.blade.php | 3 ++- .../views/livewire/project/application/swarm.blade.php | 4 ++++ resources/views/livewire/project/new/select.blade.php | 1 + resources/views/livewire/server/swarm.blade.php | 6 +++++- 8 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 config/deprecations.php 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/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/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/swarm.blade.php b/resources/views/livewire/project/application/swarm.blade.php index a7da02627..657e029f7 100644 --- a/resources/views/livewire/project/application/swarm.blade.php +++ b/resources/views/livewire/project/application/swarm.blade.php @@ -2,6 +2,7 @@

Swarm Configuration

+ @can('update', $application) Save @@ -13,6 +14,9 @@ @endcan
+ + {{ config('deprecations.swarm') }} +
diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 04c09ede1..c5482d9f7 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -452,6 +452,7 @@ function searchResources() {
Swarm Docker ({{ $swarmDocker->name }}) +
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.
From 53b47b0baae2bd33a4a2d5963e10aa102fdec7cd Mon Sep 17 00:00:00 2001 From: DarkMaper Date: Sat, 18 Apr 2026 23:35:37 +0200 Subject: [PATCH 11/68] feat(service): update docker-compose according to the official doc --- templates/compose/plane.yaml | 44 ++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index 346b0c664..5dffe0066 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -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: From beeed85e438037300f55ef3ec96595bf22691193 Mon Sep 17 00:00:00 2001 From: DarkMaper Date: Sat, 18 Apr 2026 23:35:59 +0200 Subject: [PATCH 12/68] feat(service): enable plane --- templates/compose/plane.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index 5dffe0066..cb9734fe1 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -1,4 +1,3 @@ -# ignore: true # documentation: https://docs.plane.so/self-hosting/methods/docker-compose # slogan: The open source project management tool # category: productivity From a478ac66eb7037837c178d64006f83a13eca12d2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:52:52 +0200 Subject: [PATCH 13/68] refactor: scope destination and resource lookups by current team Use find_destination_for_current_team helper across resource creation flows and the destination controller. Pass full destination objects to database creation helpers instead of UUIDs so team relationships are resolved consistently before the resource is created or linked. Add feature tests covering destination, backup storage, and resource proof lookups across teams. --- .../Controllers/Api/DatabasesController.php | 16 +- app/Livewire/Destination/Show.php | 16 +- app/Livewire/Project/New/DockerCompose.php | 14 +- app/Livewire/Project/New/DockerImage.php | 11 +- .../Project/New/GithubPrivateRepository.php | 11 +- .../New/GithubPrivateRepositoryDeployKey.php | 11 +- .../Project/New/PublicGitRepository.php | 19 +- app/Livewire/Project/New/SimpleDockerfile.php | 11 +- app/Livewire/Project/Resource/Create.php | 29 +- .../Project/Shared/ResourceOperations.php | 5 +- app/Livewire/Storage/Resources.php | 8 +- app/Models/StandaloneDocker.php | 10 + app/Models/SwarmDocker.php | 10 + bootstrap/helpers/databases.php | 51 ++- bootstrap/helpers/shared.php | 40 +-- tests/Feature/TeamScopedBackupStorageTest.php | 106 +++++++ tests/Feature/TeamScopedDestinationTest.php | 297 ++++++++++++++++++ .../Feature/TeamScopedResourceProofsTest.php | 96 ++++++ 18 files changed, 607 insertions(+), 154 deletions(-) create mode 100644 tests/Feature/TeamScopedBackupStorageTest.php create mode 100644 tests/Feature/TeamScopedDestinationTest.php create mode 100644 tests/Feature/TeamScopedResourceProofsTest.php diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 8e31a7051..f3783696d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -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); } @@ -1821,7 +1821,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); } @@ -1880,7 +1880,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); } @@ -1936,7 +1936,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); } @@ -1973,7 +1973,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); } @@ -2022,7 +2022,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); } @@ -2058,7 +2058,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); } @@ -2116,7 +2116,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); } 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/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 0222008b0..86e407136 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; @@ -178,13 +176,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 f8642d6fc..5a6f288b3 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; @@ -130,13 +128,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 dbfa15a55..b350538ac 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 @@ -284,16 +280,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(); @@ -371,12 +364,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/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/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/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/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 cd773f6a9..88a2c645e 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; @@ -259,6 +261,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()) { @@ -3489,34 +3501,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/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); +}); From f77cc91b831b3f73ff06278152f5decc3ccf3006 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:55:36 +0200 Subject: [PATCH 14/68] refactor(admin): use named routes for admin index navigation Replace Referer-based redirects in Admin Index back() and switchUser() with named routes (admin.index and dashboard) for consistent navigation behavior independent of the request header. Add tests verifying back() returns to admin.index, switchUser routes to the dashboard, and the Referer header is no longer consulted. --- app/Livewire/Admin/Index.php | 4 +- .../Feature/AdminAccessAuthorizationTest.php | 47 +++++++++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) 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/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 () { From bafb9a5a8baf8518a5b9c1cda59f158f5e726436 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:52:23 +0200 Subject: [PATCH 15/68] refactor(webhook): encrypt manual webhook secrets and tighten HMAC verification - Auto-generate a 40-char random secret for each manual_webhook_secret_* column on Application creation so new apps are never left with an empty secret. - Add encrypted cast for the four webhook-secret columns; backfill migration re-encrypts existing plaintext values and fills missing ones. - Reject webhook deliveries when the stored secret is empty (GitHub, GitLab, Bitbucket, Gitea manual endpoints). - Bitbucket: require the sha256 algorithm prefix on X-Hub-Signature instead of trusting the client-supplied algo. - GitLab: drop the ?? '' fallback on the token comparison. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Webhook/Bitbucket.php | 23 +- app/Http/Controllers/Webhook/Gitea.php | 9 + app/Http/Controllers/Webhook/Github.php | 9 + app/Http/Controllers/Webhook/Gitlab.php | 11 +- app/Models/Application.php | 23 +- ...0_backfill_and_encrypt_webhook_secrets.php | 59 +++ tests/Feature/Webhook/WebhookHmacTest.php | 338 ++++++++++++++++++ 7 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php create mode 100644 tests/Feature/Webhook/WebhookHmacTest.php 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/Models/Application.php b/app/Models/Application.php index fef6f6e4c..85e94bfd6 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/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/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); + }); +}); From e7bbd45408f97e9c2703c6c66c91cc758aa04905 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:41:47 +0200 Subject: [PATCH 16/68] refactor(api): validate and throttle feedback endpoint - Validate content (required string, min:10, max:2000) in OtherController@feedback - Register 'feedback' named rate limiter (3/min per user or IP) in RouteServiceProvider - Apply throttle:feedback middleware to POST /api/feedback - Forward to Discord with allowed_mentions.parse=[] and a 5s HTTP timeout Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Api/OtherController.php | 10 +- app/Providers/RouteServiceProvider.php | 4 + routes/api.php | 5 +- tests/Feature/FeedbackEndpointTest.php | 96 ++++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/FeedbackEndpointTest.php 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/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/routes/api.php b/routes/api.php index 0d3edcced..161d08c13 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'], @@ -218,7 +219,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/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' => []]; + }); +}); From 233f06385010bd3f13e1b3d6545315f8789f60b3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:46:42 +0200 Subject: [PATCH 17/68] refactor(help): cap feedback subject length to 255 characters Keep composed feedback payload within the server-side 2000-char budget (prefix ~56 + email 255 + subject 255 + description 1000 = 1566). Co-Authored-By: Claude Opus 4.7 --- app/Livewire/Help.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index 490515875..2786ae703 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:255'])] public string $subject; public function submit() From 434f91f83c2f05d6992254753a6d8510da12c762 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:48:34 +0200 Subject: [PATCH 18/68] refactor(help): raise feedback subject cap to 600 characters Align composed payload size with the 2000-char backend budget (prefix ~56 + email 255 + subject 600 + description 1000 = 1911). Co-Authored-By: Claude Opus 4.7 --- app/Livewire/Help.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index 2786ae703..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', 'max:255'])] + #[Validate(['required', 'min:3', 'max:600'])] public string $subject; public function submit() From 0620496c5fc527b9f2fe810c7e9014e8244d510a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:17:47 +0200 Subject: [PATCH 19/68] fix(server): exclude persistent resources from container prune Prevent docker container prune from removing containers labeled as database, application, or service types. Previously only proxy containers were excluded, risking accidental cleanup of active resources. --- app/Actions/Server/CleanupDocker.php | 2 +- tests/Unit/Actions/Server/CleanupDockerTest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 0d9ca0153..98cce088b 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 images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", 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'], From 5019c8db928afd34c0c9d17c5d20019fa053c344 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:26:47 +0200 Subject: [PATCH 20/68] fix(api): use explicit team ID for S3 storage lookup in backup endpoints Replace `ownedByCurrentTeam()` (session-based) with `ownedByCurrentTeamAPI($teamId)` (explicit team ID) when resolving S3 storage in create_backup and update_backup. Session-based team resolution is unreliable in API context where auth is token-based. Add `S3Storage::ownedByCurrentTeamAPI(int $teamId)` scope and update feature tests to use real model instances instead of Mockery mocks. --- .../Controllers/Api/DatabasesController.php | 8 +- app/Models/S3Storage.php | 7 + .../Feature/DatabaseBackupCreationApiTest.php | 208 ++++++++++++------ 3 files changed, 146 insertions(+), 77 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f3783696d..8241e5fba 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -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')) { @@ -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')) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index d6feccc7e..e02e07a4e 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -66,6 +66,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; 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']); }); }); From 410a9a6195a2b939d4a429f6c464ff56e61177f8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:27:10 +0200 Subject: [PATCH 21/68] refactor(volumes): validate input and escape shell args Tighten validation on volume name and host path inputs across Livewire + API storage endpoints and escape shell arguments in volume clone and compose preview cleanup paths. --- .../Api/ApplicationsController.php | 4 +- .../Controllers/Api/DatabasesController.php | 4 +- .../Controllers/Api/ServicesController.php | 4 +- app/Jobs/VolumeCloneJob.php | 29 ++++--- app/Livewire/Project/Service/Storage.php | 8 +- app/Livewire/Project/Shared/Storages/Show.php | 29 +++++-- app/Models/ApplicationPreview.php | 14 ++- tests/Unit/PersistentVolumeSecurityTest.php | 85 +++++++++++++++++++ 8 files changed, 148 insertions(+), 29 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index cc7c0dbbe..eb2e7fc53 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4121,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', ]); @@ -4299,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', diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 8241e5fba..3067d98e7 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -3496,7 +3496,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 +3694,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/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/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/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/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/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/tests/Unit/PersistentVolumeSecurityTest.php b/tests/Unit/PersistentVolumeSecurityTest.php index fdce223d3..287045534 100644 --- a/tests/Unit/PersistentVolumeSecurityTest.php +++ b/tests/Unit/PersistentVolumeSecurityTest.php @@ -96,3 +96,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', +]); From af0a8badb3cd9f470cb55c5f714263f63425d40b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:45:00 +0200 Subject: [PATCH 22/68] refactor(backup): validate database backup upload file type and size Add allowlist of backup file extensions (sql, sql.gz, tar, tgz, zip, dump, bak, bson, archive, bz2, xz, and compound variants) and enforce a 10 GiB maximum file size on the backup upload endpoint. Validation runs early on each chunk using the dropzone metadata and again on the assembled file. Also drops the unused createFilename helper and the commented-out S3 block. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/UploadController.php | 94 ++++++++++++++----- .../DatabaseBackupUploadValidationTest.php | 62 ++++++++++++ 2 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/DatabaseBackupUploadValidationTest.php 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/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); + } +}); From 297e9c41e19958f6237919794c28c3fb1d4cda32 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:19 +0200 Subject: [PATCH 23/68] refactor(storage): tighten S3 endpoint URL validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the existing SafeWebhookUrl rule on the S3 Storage endpoint field so the create and edit forms go through the same URL-normalization path as webhook settings. Adds a matching guard inside S3Storage::testConnection() so background callers (scheduled backups, database import reuse) also validate the endpoint before building the S3 client. Also fixes an IPv6-bracket edge case in SafeWebhookUrl so `http://[::1]` style hosts are normalized before the filter_var IP check — the rule's own loopback test was already asserting this behaviour. --- app/Livewire/Storage/Create.php | 4 +- app/Livewire/Storage/Form.php | 4 +- app/Models/S3Storage.php | 10 ++ app/Rules/SafeWebhookUrl.php | 10 +- .../Unit/S3StorageEndpointValidationTest.php | 91 +++++++++++++++++++ 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/S3StorageEndpointValidationTest.php 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/Models/S3Storage.php b/app/Models/S3Storage.php index d6feccc7e..8a2e2b102 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 { @@ -132,6 +134,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/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/tests/Unit/S3StorageEndpointValidationTest.php b/tests/Unit/S3StorageEndpointValidationTest.php new file mode 100644 index 000000000..9ffba6a30 --- /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', +]); From 4d836888964ee5a1bea7089d3fe6c886012f0bff Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:30 +0200 Subject: [PATCH 24/68] refactor(api): return generic error messages for upstream and storage failures Replace exception text in 5xx JSON responses with stable, action-specific messages so API consumers get a consistent payload regardless of which underlying client (Guzzle, PDO, filesystem) raised the exception. The previous responses concatenated the raw upstream error, which produced inconsistent messages and unnecessary noise for clients trying to parse errors programmatically. Touched endpoints: - GET /api/v1/hetzner/{locations,server-types,images,ssh-keys} - POST /api/v1/servers/hetzner - DELETE /api/v1/databases/{uuid}/backups/{uuid} - DELETE /api/v1/databases/{uuid}/backups/{uuid}/executions/{uuid} - /download/backup/{uuid} The RateLimitException branch and AuthenticationException flow keep their existing curated messages. Adds Pest coverage for the four Hetzner GET endpoints to lock the response shape on upstream failure. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/Api/DatabasesController.php | 4 +- .../Controllers/Api/HetznerController.php | 10 +-- routes/web.php | 2 +- tests/Feature/HetznerApiTest.php | 71 +++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 3067d98e7..6749d224b 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2332,7 +2332,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 +2452,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); } } 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/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/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index bd316ca49..3e8555b11 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -446,3 +446,74 @@ $response->assertStatus(401); }); }); + +describe('GHSA-m8wx-q63q-3w6c — 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'); + }); +}); From dc9322b11f5f4ab96e56af0df7d4f877e96e5e4c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:51:27 +0200 Subject: [PATCH 25/68] refactor(settings): validate dev_helper_version and escape build args Constrain dev_helper_version to Docker tag grammar ([A-Za-z0-9_][A-Za-z0-9_.-]{0,127}), re-validate before triggering the helper image build, and interpolate the image reference via escapeshellarg() when composing the docker build command. --- app/Livewire/Settings/Index.php | 14 ++- .../DevHelperVersionValidationTest.php | 90 +++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/DevHelperVersionValidationTest.php 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/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'); +}); From e373037a2a1891eb45df9e11c638817888fad65c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:07:42 +0200 Subject: [PATCH 26/68] test: remove GHSA advisory IDs from test descriptions and comments Strip advisory identifiers (GHSA-*) from describe blocks, test docblocks, and inline comments. Replace with plain descriptive labels. Also clean up FQCNs to use imported class names and minor style fixes (string concatenation spacing). --- .../Feature/CommandInjectionSecurityTest.php | 2 +- .../CrossTeamIdorServerProjectTest.php | 34 +++++++++++-------- tests/Feature/HetznerApiTest.php | 2 +- tests/Unit/FileStorageSecurityTest.php | 2 +- tests/Unit/GitRefValidationTest.php | 2 +- tests/Unit/InsecurePrngArchTest.php | 2 -- tests/Unit/LogDrainCommandInjectionTest.php | 2 +- tests/Unit/PersistentVolumeSecurityTest.php | 1 - .../Unit/S3StorageEndpointValidationTest.php | 8 ++--- 9 files changed, 28 insertions(+), 27 deletions(-) diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index bbd69ecfe..d48e03332 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -676,7 +676,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/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index 3e8555b11..b5950f9fc 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -447,7 +447,7 @@ }); }); -describe('GHSA-m8wx-q63q-3w6c — error responses do not leak exception details', function () { +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([ 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 287045534..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 diff --git a/tests/Unit/S3StorageEndpointValidationTest.php b/tests/Unit/S3StorageEndpointValidationTest.php index 9ffba6a30..054606a25 100644 --- a/tests/Unit/S3StorageEndpointValidationTest.php +++ b/tests/Unit/S3StorageEndpointValidationTest.php @@ -8,14 +8,14 @@ uses(TestCase::class); /** - * Regression tests for GHSA-pwm4-w33c-wjf3 — SSRF via S3 Storage endpoint. + * Regression tests for SSRF via S3 Storage endpoint. * * The Livewire forms (Create.php, Form.php) and the model-level defense in * S3Storage::testConnection() share the same SafeWebhookUrl rule. These tests - * assert the rule rejects the concrete payloads from the advisory PoC and - * that the model refuses to build an S3 client for an unsafe endpoint. + * assert the rule rejects the concrete payloads and that the model refuses to + * build an S3 client for an unsafe endpoint. */ -it('rejects SSRF payloads from the GHSA-pwm4-w33c-wjf3 advisory', function (string $endpoint) { +it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) { $validator = Validator::make( ['endpoint' => $endpoint], ['endpoint' => ['required', 'max:255', new SafeWebhookUrl]], From 9b37a1a7eb98a9c7ee88d565f01a4ff50d529425 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:08:46 +0200 Subject: [PATCH 27/68] refactor(auth): drop implicit email verification on invitation link login The invitation-link login path previously marked the account as email-verified as a side effect of authenticating, without the user ever proving control of the mailbox. Remove that branch so every account goes through the standard signed-URL verification flow. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Controller.php | 4 -- .../LinkLoginEmailVerificationTest.php | 60 +++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/LinkLoginEmailVerificationTest.php diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 17d14296b..a6b7f6440 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -94,10 +94,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/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); + }); +}); From 49b5472961dcd8d698d802c14a73671e8b44ad39 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:09:17 +0200 Subject: [PATCH 28/68] refactor(auth): upgrade email verification hash to sha256 Move the email-verification URL hash from sha1 to sha256 and verify it directly in the controller using hash_equals, instead of going through Laravel's EmailVerificationRequest (which only compares against sha1). The signed URL still carries the authoritative HMAC; the hash upgrade keeps the identity binding aligned with modern hashing guidance. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Controller.php | 26 +++++++- app/Models/User.php | 2 +- tests/Feature/EmailVerificationHashTest.php | 73 +++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/EmailVerificationHashTest.php diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index a6b7f6440..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); } 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/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); + }); +}); From bb0c3501efedac884e1f9b8621406fa31ce98af7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:09:30 +0200 Subject: [PATCH 29/68] refactor(cli): validate --date and escape shell args on logs:scheduled Reject malformed --date values with a clear error before building any shell command, and wrap every interpolated value (log paths, filter expression, line count) in escapeshellarg() so filter options and date values can no longer break out of the tail/grep pipeline. Co-Authored-By: Claude Opus 4.7 --- app/Console/Commands/ViewScheduledLogs.php | 30 ++++++++++------ .../Feature/ScheduledLogsCommandInputTest.php | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/ScheduledLogsCommandInputTest.php 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/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); + }); +}); From 03bf3d53536194c188d2ed708334403ddb85b5d9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:11:28 +0200 Subject: [PATCH 30/68] fix(database): use && instead of || for conf null/empty checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `||` caused config volumes to mount even when conf was null, since `!is_null(null)` is false but `!empty(null)` is true — condition always evaluated to true. --- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartRedis.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index fe80a7d54..ec09b7392 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -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/StartMysql.php b/app/Actions/Database/StartMysql.php index 337516405..1b2b338d3 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'] ?? [], [ 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', From 64753b41364010f5e8d539194a567feea1d1d520 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:06:55 +0200 Subject: [PATCH 31/68] fix(database): prevent command injection in healthcheck via CMD exec-form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace CMD-SHELL string interpolation with CMD exec-form arrays in healthcheck configs for PostgreSQL, Dragonfly, KeyDB, and ClickHouse. CMD-SHELL passes the string to /bin/sh -c, allowing command injection through user-controlled fields (username, password, dbname). CMD exec-form bypasses the shell entirely — each value is a discrete argv element. Fixes GHSA-gvc4-f276-r88p. Adds regression tests covering semicolon, pipe, backtick, $(), background operator, redirect, newline, and null-byte injection vectors. --- app/Actions/Database/StartClickhouse.php | 2 +- app/Actions/Database/StartDragonfly.php | 2 +- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartPostgresql.php | 5 +- ...atabaseHealthcheckCommandInjectionTest.php | 107 ++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/DatabaseHealthcheckCommandInjectionTest.php 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 ec09b7392..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, diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 41e39c811..81cffeb94 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, diff --git a/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php b/tests/Unit/DatabaseHealthcheckCommandInjectionTest.php new file mode 100644 index 000000000..b8c2b8a5d --- /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'); +}); From 1002d211d082e21ec5b64dcb529548a09203ca94 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:08:56 +0200 Subject: [PATCH 32/68] style(database): wrap public port inputs in flex-col gap-2 container Add wrapper div around publicPort and publicPortTimeout inputs across all database general settings views for consistent vertical spacing. --- .../database/clickhouse/general.blade.php | 4 ++-- .../database/dragonfly/general.blade.php | 17 +++++++++-------- .../project/database/keydb/general.blade.php | 11 ++++++----- .../project/database/mariadb/general.blade.php | 2 ++ .../project/database/mongodb/general.blade.php | 2 ++ .../project/database/mysql/general.blade.php | 2 ++ .../database/postgresql/general.blade.php | 10 ++++++---- .../project/database/redis/general.blade.php | 10 ++++++---- 8 files changed, 35 insertions(+), 23 deletions(-) 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 @@
- - +
+ + +
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 1b2b338d3..0394d50b6 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -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/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 6749d224b..c05af152f 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')) { @@ -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', @@ -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)) { @@ -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); @@ -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); @@ -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); @@ -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); @@ -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)) { @@ -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)) { diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index e06629d10..3a367844a 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -76,8 +76,8 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'clickhouseAdminUser' => 'required|string', - 'clickhouseAdminPassword' => 'required|string', + 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(), + 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -96,10 +96,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..38f11ec21 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -89,7 +89,7 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'dragonflyPassword' => 'required|string', + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -109,8 +109,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..1832091b3 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -92,7 +92,7 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', - 'keydbPassword' => 'required|string', + 'keydbPassword' => ValidationPatterns::databasePasswordRules(), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -114,8 +114,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..b178dd969 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -74,10 +74,10 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mariadbRootPassword' => 'required', - 'mariadbUser' => 'required', - 'mariadbPassword' => 'required', - 'mariadbDatabase' => 'required', + 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(), + 'mariadbPassword' => ValidationPatterns::databasePasswordRules(), + 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(), 'mariadbConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -97,10 +97,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..5c08b76e8 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -75,9 +75,9 @@ protected function rules(): array 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'mongoConf' => 'nullable', - 'mongoInitdbRootUsername' => 'required', - 'mongoInitdbRootPassword' => 'required', - 'mongoInitdbDatabase' => 'required', + 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(), + 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(), 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', @@ -97,9 +97,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..7671ec572 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -76,10 +76,10 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'mysqlRootPassword' => 'required', - 'mysqlUser' => 'required', - 'mysqlPassword' => 'required', - 'mysqlDatabase' => 'required', + 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(), + 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(), + 'mysqlPassword' => ValidationPatterns::databasePasswordRules(), + 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(), 'mysqlConf' => 'nullable', 'image' => 'required', 'portsMappings' => ValidationPatterns::portMappingRules(), @@ -100,10 +100,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..8c23bce8e 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -86,9 +86,9 @@ protected function rules(): array return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'postgresUser' => 'required', - 'postgresPassword' => 'required', - 'postgresDb' => 'required', + 'postgresUser' => ValidationPatterns::databaseIdentifierRules(), + 'postgresPassword' => ValidationPatterns::databasePasswordRules(), + 'postgresDb' => ValidationPatterns::databaseIdentifierRules(), 'postgresInitdbArgs' => 'nullable', 'postgresHostAuthMethod' => 'nullable', 'postgresConf' => 'nullable', @@ -112,9 +112,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.', diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 3c32a6192..114c73a42 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -81,8 +81,8 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'redisUsername' => 'required', - 'redisPassword' => 'required', + 'redisUsername' => ValidationPatterns::databaseIdentifierRules(), + 'redisPassword' => ValidationPatterns::databasePasswordRules(), 'enableSsl' => 'boolean', ]; } @@ -100,8 +100,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/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 88121384f..c8f4171eb 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -66,6 +66,98 @@ 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). + */ + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + $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. + */ + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "min:$minLength"; + $rules[] = "max:$maxLength"; + $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/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/DatabaseSslCredentialEscapingTest.php b/tests/Unit/DatabaseSslCredentialEscapingTest.php new file mode 100644 index 000000000..578c727da --- /dev/null +++ b/tests/Unit/DatabaseSslCredentialEscapingTest.php @@ -0,0 +1,149 @@ +toContain("'postgres':'postgres'") + ->toContain('docker exec abc123 bash -c'); +}); + +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('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'); +}); From 40a9881ef2381f3f4db2dc004dd11b8a0dd3a6a2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:45:57 +0200 Subject: [PATCH 35/68] fix(database): skip credential pattern validation for unchanged values Pattern enforcement now conditional on field being dirty (changed vs saved value). Prevents false validation failures when existing records hold legacy credential formats that pre-date the stricter regex rules. --- .../Project/Database/Clickhouse/General.php | 8 +- .../Project/Database/Dragonfly/General.php | 4 +- .../Project/Database/Keydb/General.php | 4 +- .../Project/Database/Mariadb/General.php | 16 +++- .../Project/Database/Mongodb/General.php | 12 ++- .../Project/Database/Mysql/General.php | 16 +++- .../Project/Database/Postgresql/General.php | 12 ++- .../Project/Database/Redis/General.php | 8 +- app/Support/ValidationPatterns.php | 22 ++++- .../DatabaseCredentialDirtyValidationTest.php | 87 +++++++++++++++++++ 10 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/DatabaseCredentialDirtyValidationTest.php diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 3a367844a..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' => ValidationPatterns::databaseIdentifierRules(), - 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(), + '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', diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 38f11ec21..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' => ValidationPatterns::databasePasswordRules(), + 'dragonflyPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 1832091b3..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' => ValidationPatterns::databasePasswordRules(), + 'keydbPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->keydbPassword !== $this->database->keydb_password, + ), 'image' => 'required|string', 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index b178dd969..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' => ValidationPatterns::databasePasswordRules(), - 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(), - 'mariadbPassword' => ValidationPatterns::databasePasswordRules(), - 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(), + '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(), diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 5c08b76e8..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' => ValidationPatterns::databaseIdentifierRules(), - 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(), - 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(), + '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', diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 7671ec572..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' => ValidationPatterns::databasePasswordRules(), - 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(), - 'mysqlPassword' => ValidationPatterns::databasePasswordRules(), - 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(), + '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(), diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 8c23bce8e..a9a4115fd 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' => ValidationPatterns::databaseIdentifierRules(), - 'postgresPassword' => ValidationPatterns::databasePasswordRules(), - 'postgresDb' => ValidationPatterns::databaseIdentifierRules(), + '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', diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 114c73a42..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' => ValidationPatterns::databaseIdentifierRules(), - 'redisPassword' => ValidationPatterns::databasePasswordRules(), + 'redisUsername' => ValidationPatterns::databaseIdentifierRules( + enforcePattern: $this->redisUsername !== $this->database->redis_username, + ), + 'redisPassword' => ValidationPatterns::databasePasswordRules( + enforcePattern: $this->redisPassword !== $this->database->redis_password, + ), 'enableSsl' => 'boolean', ]; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index c8f4171eb..09c40a466 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -82,8 +82,12 @@ class ValidationPatterns /** * 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): array + public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array { $rules = []; @@ -96,7 +100,10 @@ public static function databaseIdentifierRules(bool $required = true, int $minLe $rules[] = 'string'; $rules[] = "min:$minLength"; $rules[] = "max:$maxLength"; - $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN; + } return $rules; } @@ -117,8 +124,12 @@ public static function databaseIdentifierMessages(string $field, string $label = /** * 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): array + public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array { $rules = []; @@ -131,7 +142,10 @@ public static function databasePasswordRules(bool $required = true, int $minLeng $rules[] = 'string'; $rules[] = "min:$minLength"; $rules[] = "max:$maxLength"; - $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + + if ($enforcePattern) { + $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN; + } return $rules; } 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(); +}); From 90ddbb357231ca3808f277eb87a63c8f650417e6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:28:38 +0200 Subject: [PATCH 36/68] feat(security): support expiration on API tokens with warning notifications Add optional expiration to personal API tokens. Users pick a duration (1/7/30/60/90 days or Never) at creation time. Expired tokens are rejected by Sanctum, pruned hourly by sanctum:prune-expired, and a team notification fires ~24h before expiry so owners can rotate before API calls start failing. - ApiTokens Livewire component stores expires_at from expiresInDays - Rework issued-tokens UI from card grid to table (matches other views) - New ApiTokenExpirationWarningJob scheduled hourly (idempotent via RateLimiter) - New ApiTokenExpiringNotification (email/discord/telegram/slack/pushover) - api_token_expiring added to alwaysSendEvents so users cannot silence expiry warnings from the per-event notification toggle UI - sanctum:prune-expired cadence moved from daily to hourly Co-Authored-By: Claude Opus 4.7 --- app/Console/Kernel.php | 3 + app/Jobs/ApiTokenExpirationWarningJob.php | 49 ++++++++ app/Livewire/Security/ApiTokens.php | 14 ++- .../ApiTokenExpiringNotification.php | 103 ++++++++++++++++ app/Traits/HasNotificationSettings.php | 1 + .../views/emails/api-token-expiring.blade.php | 7 ++ .../livewire/security/api-tokens.blade.php | 113 ++++++++++++------ tests/Feature/ApiTokenExpirationTest.php | 81 +++++++++++++ .../Feature/ApiTokenExpirationWarningTest.php | 83 +++++++++++++ 9 files changed, 419 insertions(+), 35 deletions(-) create mode 100644 app/Jobs/ApiTokenExpirationWarningJob.php create mode 100644 app/Notifications/ApiTokenExpiringNotification.php create mode 100644 resources/views/emails/api-token-expiring.blade.php create mode 100644 tests/Feature/ApiTokenExpirationTest.php create mode 100644 tests/Feature/ApiTokenExpirationWarningTest.php 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/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/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/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/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/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/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/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(); + }); +}); From a05d4e3a4b024719cda512244549fb5c949180c3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:26:34 +0200 Subject: [PATCH 37/68] fix(database): tighten Postgres init script filename handling Validate new init-script filenames against path traversal and shell metacharacters via a new validateFilenameSafe() helper, and harden the write/delete paths with basename() + escapeshellarg() so legacy rows still deploy and can be cleaned up without regressions. Co-Authored-By: Claude Opus 4.7 --- app/Actions/Database/StartPostgresql.php | 13 +- .../Project/Database/Postgresql/General.php | 23 ++- bootstrap/helpers/shared.php | 67 +++++++++ .../Unit/PostgresqlInitScriptSecurityTest.php | 66 +++++++++ tests/Unit/ValidateFilenameSafeTest.php | 138 ++++++++++++++++++ 5 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/ValidateFilenameSafeTest.php diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 81cffeb94..52ff64ebf 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -301,9 +301,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/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index a9a4115fd..b5fb85483 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -358,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); @@ -404,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}"; @@ -443,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/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 77ca64fbd..881211513 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -157,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. * 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/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); +}); From f0e955bf4562aab85d981e30acd467ea3cd94cbb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:41:48 +0200 Subject: [PATCH 38/68] refactor(database): escape postgres_user in SSL chown command Apply escapeshellarg() to the Postgres username before interpolating it into the chown command used to fix SSL certificate ownership, matching the handling already in place for StartMysql. This keeps the sink-side escaping consistent across database actions, independent of upstream input validation. Also adjusts an assertion in DatabaseSslCredentialEscapingTest to match the actual double-escaped output of executeInDocker, and adds Postgres regression cases for subshell and semicolon payloads. --- app/Actions/Database/StartPostgresql.php | 3 ++- .../DatabaseSslCredentialEscapingTest.php | 25 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 52ff64ebf..da8b5dc4e 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -224,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.'"; diff --git a/tests/Unit/DatabaseSslCredentialEscapingTest.php b/tests/Unit/DatabaseSslCredentialEscapingTest.php index 578c727da..31f0133a0 100644 --- a/tests/Unit/DatabaseSslCredentialEscapingTest.php +++ b/tests/Unit/DatabaseSslCredentialEscapingTest.php @@ -14,8 +14,11 @@ $escaped = escapeshellarg($user); $cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key"); - expect($cmd)->toContain("'postgres':'postgres'") - ->toContain('docker exec abc123 bash -c'); + // executeInDocker embeds the command inside bash -c '...', escaping inner single quotes as '\'' + // so escapeshellarg('postgres') = 'postgres' becomes '\''postgres'\'' in the outer shell string + expect($cmd)->toContain('bash -c') + ->toContain('postgres') + ->toContain('chown'); }); it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () { @@ -53,6 +56,24 @@ 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); From 817128c5affa02c1a8f0f1f9a8df54b9dd80bcc1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:00:41 +0200 Subject: [PATCH 39/68] refactor(validation): tokenize shell-safe command pattern Replace the flat character-class regex for SHELL_SAFE_COMMAND_PATTERN with a token-aware alternation. The parser now recognizes explicit tokens (`&&`, `||`, balanced single/double quotes, whitespace, and an unquoted safe-char run) instead of a bag of characters, which lets us extend the accepted grammar without loosening the guarantees. New surface area, with tests: - logical OR chaining (`make build || make clean`) - shell globs and bang (`rm *.tmp`, `cp src/?.js dist/`, `! grep -q foo`) - single-quoted arguments are now treated as balanced runs rather than rejected per-character Preserved surface area: - && chaining, balanced "..." and '...' quotes, the previous safe path / argument characters, and the existing error-path contract in ApplicationDeploymentJob::validateShellSafeCommand(). Also refreshes the user-facing validation messages in General.php so the allow/deny list shown on failure matches the new grammar. Co-Authored-By: Claude Opus 4.7 --- app/Livewire/Project/Application/General.php | 12 +- app/Support/ValidationPatterns.php | 32 +++-- .../Feature/CommandInjectionSecurityTest.php | 125 +++++++++++++++++- 3 files changed, 153 insertions(+), 16 deletions(-) diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 25ce82eb0..f89d16912 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/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 09c40a466..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 diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index d48e03332..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 () { From f1f53a31ab94227bcac767ec0dac8f9cffca76d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:31:57 +0000 Subject: [PATCH 40/68] build(deps): bump follow-redirects in /docker/coolify-realtime Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- docker/coolify-realtime/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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", From 4e561264b4a78057f2843340d1f75e0420cd0ff2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:58:38 +0200 Subject: [PATCH 41/68] docs(sponsors): add PrivateAlps to Huge and YouStable to Small sponsors --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a5feff4e..494ad007a 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) - Offshore hosting — anonymity, uncensored, security. ### Big Sponsors @@ -151,6 +151,7 @@ ### Small Sponsors Cap-go InterviewPal Transcript LOL +YouStable ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From 0c1c5c5831f375867e1c8c410ec7300ddeb2b405 Mon Sep 17 00:00:00 2001 From: tiago <70700766+tiagozip@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:03:06 +0100 Subject: [PATCH 42/68] feat: add Cap to templates --- public/svgs/cap-captcha.png | Bin 0 -> 32805 bytes templates/compose/cap-captcha.yaml | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 public/svgs/cap-captcha.png create mode 100644 templates/compose/cap-captcha.yaml diff --git a/public/svgs/cap-captcha.png b/public/svgs/cap-captcha.png new file mode 100644 index 0000000000000000000000000000000000000000..4b6a7df1424b62b5d5e72c0754fdd509fdf6a90b GIT binary patch literal 32805 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�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 N-(kmh_QV9?{{wyt;b;H= literal 0 HcmV?d00001 diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml new file mode 100644 index 000000000..365dc4a2f --- /dev/null +++ b/templates/compose/cap-captcha.yaml @@ -0,0 +1,27 @@ +# documentation: https://capjs.js.org +# slogan: The self-hosted CAPTCHA for the modern web. +# tags: captcha,security,privacy,proof-of-work +# logo: svgs/cap-captcha.png +# port: 3000 + +services: + cap: + image: tiago2/cap:latest + environment: + - SERVICE_FQDN_CAP_3000 + - ADMIN_KEY=$SERVICE_PASSWORD_ADMIN + - REDIS_URL=redis://valkey:6379 + 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 From 19767a569be463e86e4040a01b49676c63cb07c2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:55:09 +0200 Subject: [PATCH 43/68] fix(navigation): replace wire:navigate.hover with wire:navigate Remove hover prefetching variant from SPA navigation helper, both in the happy path and the exception fallback. --- bootstrap/helpers/shared.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 881211513..9f0f2cd73 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3532,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'; } } From 833f5769e557e5ce2cbf56b56d1e2ee5b2712a35 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:06:07 +0530 Subject: [PATCH 44/68] fix(service): docs link on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 365dc4a2f..561c589ee 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -1,4 +1,4 @@ -# documentation: https://capjs.js.org +# documentation: https://capjs.js.org/guide/ # slogan: The self-hosted CAPTCHA for the modern web. # tags: captcha,security,privacy,proof-of-work # logo: svgs/cap-captcha.png From ae1a24a83b96f571097abc3169cfe05ebe0c6ce6 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:06:25 +0530 Subject: [PATCH 45/68] fix(service): add category on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 561c589ee..9474b83cd 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -1,5 +1,6 @@ # 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 From d425998476c6c167e08d3ba107355c0ea6af222d Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:06:44 +0530 Subject: [PATCH 46/68] fix(service): service url variable on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 9474b83cd..534a53b3c 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -9,7 +9,7 @@ services: cap: image: tiago2/cap:latest environment: - - SERVICE_FQDN_CAP_3000 + - SERVICE_URL_CAP_3000 - ADMIN_KEY=$SERVICE_PASSWORD_ADMIN - REDIS_URL=redis://valkey:6379 depends_on: From 716c741fffee9e0156f1437bc9dd27e70b4ef387 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:07:00 +0530 Subject: [PATCH 47/68] fix(service): pin docker image on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 534a53b3c..2cd1a286d 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -7,7 +7,7 @@ services: cap: - image: tiago2/cap:latest + image: tiago2/cap:3.0.4 # Released on 22nd April 2026 environment: - SERVICE_URL_CAP_3000 - ADMIN_KEY=$SERVICE_PASSWORD_ADMIN From e26d4e39e62cccd2d8210c7a839fd3b1baef46de Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:07:14 +0530 Subject: [PATCH 48/68] fix(service): add healthcheck on cap-captcha.yaml --- templates/compose/cap-captcha.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/templates/compose/cap-captcha.yaml b/templates/compose/cap-captcha.yaml index 2cd1a286d..3525663cd 100644 --- a/templates/compose/cap-captcha.yaml +++ b/templates/compose/cap-captcha.yaml @@ -12,6 +12,12 @@ services: - 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 From 237313f5c74ec083116e6a885a730e8fcf7b1210 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:17:53 +0200 Subject: [PATCH 49/68] docs(sponsors): update PrivateAlps description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 494ad007a..f8ae506b2 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) - Offshore hosting — anonymity, uncensored, security. +* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control ### Big Sponsors From c5ce36018c5a9d95d45908d68e15ddcee8d555ac Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:13:55 +0200 Subject: [PATCH 50/68] docs(sponsors): add MindedTech to Small sponsors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f8ae506b2..ac1b39b06 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ ### Small Sponsors InterviewPal Transcript LOL YouStable +MindedTech ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From f77fd2161ce5b64ac7e644b63d2c6d856c28e33a Mon Sep 17 00:00:00 2001 From: Gauthier POGAM--LE MONTAGNER Date: Thu, 23 Apr 2026 18:08:40 +0200 Subject: [PATCH 51/68] feat(service): add healthcheck to langfuse-worker --- templates/compose/langfuse.yaml | 5 +++++ 1 file changed, 5 insertions(+) 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 From 32ae288a12d07f1f6a17ddc445d121b0543262a5 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:12:17 +0530 Subject: [PATCH 52/68] fix(service): add port to metadata on plane --- templates/compose/plane.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index cb9734fe1..440845a1e 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -1,6 +1,7 @@ # 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 From b3d6877404a631d80b4f3f612df9c148999ad18c Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:21:33 +0530 Subject: [PATCH 53/68] chore(service): update beszel to 0.18.7 --- templates/compose/beszel.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 5f45deedcebc51140c3334d2d0ab90daca45adcb Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:22:08 +0530 Subject: [PATCH 54/68] chore(service): update beszel-agent to 0.18.7 --- templates/compose/beszel-agent.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From cd47711cd0bfa9935759d313cac0ba1c5da4472c Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:28:08 +0530 Subject: [PATCH 55/68] feat(service): disable calcom Not maintained anymore by the calcom team --- templates/compose/calcom.yaml | 1 + 1 file changed, 1 insertion(+) 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 From 424a41dbd02f12c727345d263640c7a46e839646 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:30:57 +0530 Subject: [PATCH 56/68] fix(service): add missing category to jitsi --- templates/compose/jitsi.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/compose/jitsi.yaml b/templates/compose/jitsi.yaml index db119e4a0..7d2cad25e 100644 --- a/templates/compose/jitsi.yaml +++ b/templates/compose/jitsi.yaml @@ -1,6 +1,7 @@ # 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 # logo: svgs/jitsi.svg # port: 80 From d2b7dfe92ae9e297dc46a6bf9a890a33507a63ce Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:40:01 +0530 Subject: [PATCH 57/68] fix(service): remove volume declaration on jitsi --- templates/compose/jitsi.yaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/templates/compose/jitsi.yaml b/templates/compose/jitsi.yaml index 7d2cad25e..97699e473 100644 --- a/templates/compose/jitsi.yaml +++ b/templates/compose/jitsi.yaml @@ -137,10 +137,3 @@ services: networks: meet.jitsi: - -volumes: - jitsi-web: - jitsi-prosody: - jitsi-jicofo: - jitsi-jvb: - \ No newline at end of file From 74cc85139f1cb6ea5dacfc602534cae6decb727e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:33:32 +0200 Subject: [PATCH 58/68] docs(sponsors): add NetRouting to Small sponsors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ac1b39b06..8d9803077 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ ### Small Sponsors Transcript LOL YouStable MindedTech +NetRouting ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From 593006be88cefdf34bd3319f98e8ae541630e69f Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:27:26 +0530 Subject: [PATCH 59/68] fix(validation): allow decimals for database backups max storage --- app/Http/Controllers/Api/DatabasesController.php | 16 ++++++++-------- .../project/database/backup-edit.blade.php | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index c05af152f..a6056b9f3 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -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', ]); @@ -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()) { 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 />
From cad9fc99d6d97dc32ee0629e142c4e06def1eb34 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:53:45 +0200 Subject: [PATCH 60/68] docs(sponsors): add ParsecPH to Small sponsors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8d9803077..7a3f2a65e 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ ### Small Sponsors YouStable MindedTech NetRouting +ParsecPH ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) From 9cd379e737174c2881acf71fe8bbf0c2afdb7629 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:55:34 +0200 Subject: [PATCH 61/68] fix(helper): add Alpine.js click toggle to info helper popup Replace CSS-only hover with Alpine.js click-based open/close, including click.outside to dismiss. --- resources/views/components/helper.blade.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 !!}
From 36baf70637d864eb5680e4bb94b6fe10fce4b06d Mon Sep 17 00:00:00 2001 From: nehemiyawicks Date: Sun, 26 Apr 2026 19:30:05 +0530 Subject: [PATCH 62/68] fix: use --network host for Dockerfile buildpack builds Dockerfile buildpack was passing --network {custom_network_name} to docker build, but BuildKit only supports host, none, and default. Every other buildpack already uses --network host with --add-host flags. Aligned the Dockerfile path to match. Fixes #9804 --- app/Jobs/ApplicationDeploymentJob.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7e5025c8a..84bb4a09d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3075,29 +3075,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); From 968ae97dfcc46c772a61c55bb23d990da6bab0ec Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:01:36 +0200 Subject: [PATCH 63/68] version++ --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 743b5e38c..739d22a5b 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.474', + 'version' => '4.0.0-beta.475', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.13', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 27d911c67..e5f43093b 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-beta.475" }, "nightly": { "version": "4.0.0" diff --git a/versions.json b/versions.json index 27d911c67..e5f43093b 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.474" + "version": "4.0.0-beta.475" }, "nightly": { "version": "4.0.0" From d0ed4fa4c4978b362737d772edcff292ae9178fd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:08:58 +0200 Subject: [PATCH 64/68] version ++ finally --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 739d22a5b..7504b6ba8 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.475', + 'version' => '4.0.0', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.13', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index e5f43093b..3307b7f2e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.475" + "version": "4.0.0" }, "nightly": { "version": "4.0.0" diff --git a/versions.json b/versions.json index e5f43093b..3307b7f2e 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.475" + "version": "4.0.0" }, "nightly": { "version": "4.0.0" From 9a58e0fea25879d14d1b7f1afb7835dd7a15269f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:33:08 +0200 Subject: [PATCH 65/68] fix(logs): disable auto-scroll on user scroll-up, re-enable on scroll-to-bottom Add wheel, touch, and keyboard event handlers to log containers in deployment and get-logs views. Auto-follow disables when user scrolls up; re-enables when user scrolls back to bottom (within 10px threshold). --- .../application/deployment/show.blade.php | 51 +++++++++++- .../project/shared/get-logs.blade.php | 46 ++++++++-- templates/service-templates-latest.json | 83 ++++++++++++++----- templates/service-templates.json | 83 ++++++++++++++----- 4 files changed, 208 insertions(+), 55 deletions(-) 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-
-
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/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", From 9408620d5f47836241a9b10516296c5311786832 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:35:32 +0200 Subject: [PATCH 66/68] fix(terminal): add WS heartbeat and fix proxy idle disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxies (Cloudflare, nginx) drop idle WebSocket connections before the application notices, leaving clients typing into dead sockets. - Add server-side ping/pong heartbeat (30s) in terminal-server.js; terminate unresponsive clients instead of letting connections go stale - Move client keepAlive interval start to the connect event so it restarts correctly after reconnects - Remove hidden-tab keepalive short-circuit — server pings now own liveness; suppressing client pings while hidden masked proxy drops - Fix clearAllTimers to use clearTimeout for one-shot timers - On visibility resume, probe with a 5s timeout instead of the default 35s so half-open sockets are detected quickly - Bump coolify-realtime to 1.0.14 across all compose files --- config/constants.php | 2 +- docker-compose.prod.yml | 2 +- docker-compose.windows.yml | 2 +- docker/coolify-realtime/terminal-server.js | 22 +++++++++++ other/nightly/docker-compose.prod.yml | 2 +- other/nightly/docker-compose.windows.yml | 2 +- resources/js/terminal.js | 38 +++++++++++++------ .../Feature/RealtimeTerminalPackagingTest.php | 25 ++++++++++++ 8 files changed, 79 insertions(+), 16 deletions(-) diff --git a/config/constants.php b/config/constants.php index 7504b6ba8..f2f6946fb 100644 --- a/config/constants.php +++ b/config/constants.php @@ -4,7 +4,7 @@ 'coolify' => [ '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/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/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 3ae77857f..470f4af1e 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -105,7 +105,12 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); +const HEARTBEAT_INTERVAL_MS = 30000; + 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: [] }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); @@ -167,6 +172,23 @@ wss.on('connection', async (ws, req) => { }); }); +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 + } + }); +}, HEARTBEAT_INTERVAL_MS); + +wss.on('close', () => clearInterval(heartbeat)); + const messageHandlers = { message: (session, data) => session.ptyProcess.write(data), resize: (session, { cols, rows }) => { 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/resources/js/terminal.js b/resources/js/terminal.js index aa5f37353..923d09d86 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -75,8 +75,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 +148,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; @@ -282,6 +283,13 @@ export function initializeTerminalComponent() { this.pendingCommand = null; } + // (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 this.resetPingTimeout(); @@ -494,11 +502,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 +527,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/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index e8fa5ff76..8f4da3a62 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -32,3 +32,28 @@ ->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'"); +}); From cabcd8f699dc3ac400edeb63f3a9ac0c0c2260c6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:26:31 +0200 Subject: [PATCH 67/68] fix(terminal): add idle timeout, reconnect replay, and scrollback preservation - Kill PTY and notify client after 30 min of inactivity (IDLE_TIMEOUT_MS) - Buffer client messages during async auth/IP fetch to prevent race-condition message loss on fast reconnects - Replay last sent command after transient reconnect so PTY respawns without user interaction - Preserve scrollback on disconnect/reconnect; write visible timestamp markers instead of wiping term state - Handle idle-timeout sentinel on client with user-facing error message --- docker/coolify-realtime/terminal-server.js | 81 +++++++++++++++---- resources/js/terminal.js | 49 +++++++++-- .../Feature/RealtimeTerminalPackagingTest.php | 47 +++++++++++ 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 470f4af1e..11695173b 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -106,13 +106,24 @@ 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, @@ -122,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); @@ -153,23 +184,17 @@ 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(() => { @@ -184,14 +209,41 @@ const heartbeat = setInterval(() => { } 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) @@ -323,6 +375,7 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 923d09d86..fe13c1b21 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, @@ -162,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 = ''; @@ -277,10 +289,15 @@ 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. @@ -362,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); } @@ -395,7 +415,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(); @@ -423,6 +451,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 @@ -431,9 +460,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:')) diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index 8f4da3a62..ba01deca5 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -57,3 +57,50 @@ 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();"); +}); From 1368026f20d88045601f6d1ad03b53e9aff7d2b4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:29:32 +0200 Subject: [PATCH 68/68] fix(terminal): remove verbose websocket message logging --- docker/coolify-realtime/terminal-server.js | 6 ------ resources/js/terminal.js | 2 -- 2 files changed, 8 deletions(-) diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 11695173b..f5760279f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -271,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')) { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index fe13c1b21..7a7fc8536 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -396,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;